├── .editorconfig ├── .github └── workflows │ └── nodejs.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── package.json ├── src ├── index.js ├── lib │ ├── rollup-plugin-strip-comments.js │ ├── rollup-plugin-terser-simple.js │ ├── transform-change-webpack-urls.js │ ├── transform-extract-polyfills.js │ ├── util.js │ └── worker-pool.js └── worker.js └── test ├── _sucrase-loader.js ├── _util.js ├── _webpacks.js ├── fixtures ├── basic │ └── entry.js ├── code-splitting │ ├── about.js │ ├── entry.js │ ├── home.js │ └── profile.js └── typescript │ ├── entry.js │ ├── pinch-zoom.ts │ ├── pointer-tracker.ts │ ├── tsconfig.json │ └── util.ts ├── index.test.js └── webpacks └── 4 └── package.json /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 2 6 | end_of_line = lf 7 | charset = utf-8 8 | trim_trailing_whitespace = true 9 | insert_final_newline = true 10 | 11 | [*.md] 12 | trim_trailing_whitespace = false 13 | -------------------------------------------------------------------------------- /.github/workflows/nodejs.yml: -------------------------------------------------------------------------------- 1 | name: Node CI 2 | 3 | on: [push] 4 | 5 | jobs: 6 | build: 7 | 8 | runs-on: ubuntu-latest 9 | 10 | steps: 11 | - uses: actions/checkout@v2 12 | - name: Use Node.js ${{ matrix.node-version }} 13 | uses: actions/setup-node@v1 14 | with: 15 | node-version: '12.x' 16 | - run: npm install 17 | - run: npm run build --if-present 18 | - run: npm test 19 | env: 20 | CI: true 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | dist 4 | package-lock.json 5 | yarn.lock 6 | worker-plugin-*.tgz 7 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to Contribute 2 | 3 | We'd love to accept your patches and contributions to this project. There are 4 | just a few small guidelines you need to follow. 5 | 6 | ## Contributor License Agreement 7 | 8 | Contributions to this project must be accompanied by a Contributor License 9 | Agreement. You (or your employer) retain the copyright to your contribution, 10 | this simply gives us permission to use and redistribute your contributions as 11 | part of the project. Head over to to see 12 | your current agreements on file or to sign a new one. 13 | 14 | You generally only need to submit a CLA once, so if you've already submitted one 15 | (even if it was for a different project), you probably don't need to do it 16 | again. 17 | 18 | ## Code reviews 19 | 20 | All submissions, including submissions by project members, require review. We 21 | use GitHub pull requests for this purpose. Consult 22 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more 23 | information on using pull requests. 24 | 25 | ## Community Guidelines 26 | 27 | This project follows [Google's Open Source Community 28 | Guidelines](https://opensource.google.com/conduct/). 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Optimize Plugin for Webpack 2 | 3 | Optimize your code for modern browsers while still supporting the other 10%, 4 | increasing your build performance, reducing bundle size and improving output quality. 5 | 6 | Put simply: it compiles code faster, better and smaller. 7 | 8 | ## Features 9 | 10 | - Much faster than your current Webpack setup 11 | - Transparently optimizes all of your code 12 | - Automatically optimizes all of your dependencies 13 | - Compiles bundles for modern browsers (90%) and legacy browsers (10%) 14 | - Removes unnecessary polyfills, even when inlined into dependencies 15 | - Builds a highly-optimized automated polyfills bundle 16 | 17 | ## Install 18 | 19 | ```sh 20 | npm install --save-dev optimize-plugin 21 | ``` 22 | 23 | ## Usage 24 | 25 | First, disable any existing configuration you have to Babel, minification, and module/nomodule. 26 | 27 | Then, add `OptimizePlugin` to your Webpack plugins Array: 28 | 29 | ```js 30 | plugins: [ 31 | new OptimizePlugin({ 32 | // any options here 33 | }) 34 | ] 35 | ``` 36 | 37 | ### Options 38 | 39 | | Option | Type | Description 40 | |---|---|--- 41 | | `concurrency` | `number\|false` | Maximum number of threads to use. Default: the number of available CPUs.
_Pass `false` for single-threaded, sometimes faster for small projects._ 42 | | `sourceMap` | `boolean\|false` | Whether or not to produce source maps for the given input. | 43 | | `minify` | `boolean\|false` | Minify using Terser, if turned off only comments will be stripped. | 44 | | `downlevel` | `boolean\|true` | Produces a bundle for `nomodule` browsers. (IE11, ...) | 45 | | `modernize` | `boolean\|true` | Attempt to upgrade ES5 syntax to equivalent modern syntax. | 46 | | `verbose` | `boolean\|false` | Will log performance information and information about polyfills. | 47 | | `polyfillsFilename` | `string\|polyfills.legacy.js` | The name for the chunk containing polyfills for the legacy bundle. | 48 | | `exclude` | `RegExp[]\|[]` | Asset patterns that should be excluded | 49 | 50 | 51 | ## How does this work? 52 | 53 | Instead of running Babel on each individual source code file in your project, `optimize-plugin` 54 | transforms your entire application's bundled code. This means it can apply optimizations and 55 | transformations not only to your source, but to your dependencies - making polyfill extraction 56 | and reverse transpilation steps far more effective. 57 | 58 | This setup also allows `optimize-plugin` to achieve better performance. All work is done in 59 | a background thread pool, and the same AST is re-used for modern and legacy transformations. 60 | Previous solutions for module/nomodule have generally relied running two complete compilation 61 | passes, which incurs enormous overhead since the entire graph is built and traversed multiple 62 | times. With `optimize-plugin`, bundling and transpilation are now a separate concerns: Webpack 63 | handles graph creation and reduction, then passes its bundles to Babel for transpilation. 64 | 65 | 66 | 67 | ## FAQ 68 | 69 | ### What do I do with my current Babel configuration? 70 | 71 | In order to migrate to optimize-plugin, you'll need to move your babel configuration into a `.babelrc` or `babel.config.js` file and remove `babel-loader` from your Webpack configuration. Remember, optimize-plugin only uses your babel configuration when generating _modern_ bundles. Legacy bundles are automatically compiled to ES5 without looking at your Babel configuration, though you can customize their compilation by defining a [browserslist](https://github.com/browserslist/browserslist) field in your package.json. 72 | 73 | ### Do I need to include any polyfills manually? 74 | 75 | In general, adopting optimize-plugin means removing all of your current polyfills, since the plugin automatically detects and polyfills JavaScript features for legacy bundles. The plugin does _not_ polyfill DOM features though, so be sure to keep including any DOM polyfills your application relies (`ParentNode.append()`, Module Workers, etc). 76 | 77 | Remember: the premise of this plugin is that you don't need to configure JS transpilation or polyfills - it's all done automatically based on usage. 78 | 79 | ### License 80 | 81 | Apache-2.0 82 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "optimize-plugin", 3 | "version": "1.3.1", 4 | "description": "Webpack plugin to optimize bundles.", 5 | "main": "dist/optimize-plugin.js", 6 | "repository": "developit/optimize-plugin", 7 | "scripts": { 8 | "build": "microbundle --target node --format cjs --no-compress src/*.js", 9 | "prepack": "npm run build", 10 | "dev": "jest --verbose --watchAll", 11 | "test": "jest --verbose", 12 | "release": "npm t && git commit -am $npm_package_version && git tag $npm_package_version && git push && git push --tags && npm publish" 13 | }, 14 | "babel": { 15 | "env": { 16 | "test": { 17 | "plugins": [ 18 | "transform-es2015-modules-commonjs" 19 | ] 20 | } 21 | } 22 | }, 23 | "eslintConfig": { 24 | "extends": [ 25 | "standard", 26 | "plugin:jest/recommended" 27 | ], 28 | "parserOptions": { 29 | "sourceType": "module", 30 | "ecmaVersion": 2020 31 | }, 32 | "env": { 33 | "browser": true, 34 | "jest": true 35 | }, 36 | "rules": { 37 | "indent": [ 38 | 2, 39 | 2 40 | ], 41 | "semi": [ 42 | 2, 43 | "always" 44 | ] 45 | } 46 | }, 47 | "jest": { 48 | "testEnvironment": "node", 49 | "watchPathIgnorePatterns": [ 50 | "/node_modules/", 51 | "/test/fixtures/" 52 | ] 53 | }, 54 | "files": [ 55 | "src", 56 | "dist" 57 | ], 58 | "keywords": [ 59 | "webpack", 60 | "plugin", 61 | "minify", 62 | "optimize" 63 | ], 64 | "author": "Jason Miller (https://github.com/developit)", 65 | "license": "Apache-2.0", 66 | "devDependencies": { 67 | "@types/jest": "^26.0.15", 68 | "babel-plugin-transform-es2015-modules-commonjs": "^6.26.2", 69 | "clean-webpack-plugin": "^1.0.0", 70 | "eslint": "^7.14.0", 71 | "eslint-config-standard": "^16.0.2", 72 | "eslint-plugin-import": "^2.22.1", 73 | "eslint-plugin-jest": "^24.1.3", 74 | "eslint-plugin-node": "^11.1.0", 75 | "eslint-plugin-promise": "^4.0.1", 76 | "eslint-plugin-standard": "^4.1.0", 77 | "jest": "^25.5.4", 78 | "memory-fs": "^0.4.1", 79 | "microbundle": "^0.12.4", 80 | "preact": "^10.5.7", 81 | "sucrase": "^3.16.0", 82 | "ts-loader": "^9.2.6", 83 | "webpack": "^5.65.0", 84 | "webpack-4": "./test/webpacks/4" 85 | }, 86 | "dependencies": { 87 | "@babel/core": "^7.12.7", 88 | "@rollup/plugin-commonjs": "^11.1.0", 89 | "@rollup/plugin-node-resolve": "^7.1.3", 90 | "babel-preset-modernize": "0.0.5", 91 | "core-js": "^3.7.0", 92 | "gzip-size": "^5.1.1", 93 | "jest-worker": "^25.5.0", 94 | "magic-string": "^0.25.7", 95 | "regenerator-runtime": "^0.13.7", 96 | "rollup": "^1.32.1", 97 | "terser": "^5.10.0", 98 | "webpack-sources": "^3.2.2" 99 | }, 100 | "peerDependencies": { 101 | "@babel/preset-env": ">= 7.10", 102 | "webpack": ">= 4" 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import util from 'util'; 18 | import { gzip } from 'zlib'; 19 | import { promises as fs } from 'fs'; 20 | import * as defaultWebpack from 'webpack'; 21 | import { SourceMapSource, RawSource } from 'webpack-sources'; 22 | import { rollup } from 'rollup'; 23 | import commonjsPlugin from '@rollup/plugin-commonjs'; 24 | import nodeResolvePlugin from '@rollup/plugin-node-resolve'; 25 | import rollupPluginTerserSimple from './lib/rollup-plugin-terser-simple'; 26 | import rollupPluginStripComments from './lib/rollup-plugin-strip-comments'; 27 | import { getCorejsVersion, createPerformanceTimings } from './lib/util'; 28 | import { WorkerPool } from './lib/worker-pool'; 29 | 30 | const NAME = 'OptimizePlugin'; 31 | 32 | const DEFAULT_OPTIONS = { 33 | /** 34 | * Number of Worker Threads to use for running Babel and Terser. 35 | * @default os.cpus().length // number of available CPUs 36 | */ 37 | concurrency: undefined, 38 | 39 | /** 40 | * Produce Source Maps? 41 | * @default false 42 | */ 43 | sourceMap: false, 44 | 45 | /** 46 | * Minify bundles using Terser? 47 | * @default true 48 | */ 49 | minify: true, 50 | 51 | /** 52 | * Produce a set of bundles catering to older browsers alongside the default modern bundles. 53 | * @default true 54 | */ 55 | downlevel: true, 56 | 57 | /** 58 | * Attempt to upgrade ES5 syntax to equivalent modern syntax. 59 | * @default true 60 | */ 61 | modernize: true, 62 | 63 | /** 64 | * Show logs containing performance information and inlined polyfill. 65 | */ 66 | verbose: true, 67 | 68 | /** 69 | * @default "polyfills.legacy.js" 70 | */ 71 | polyfillsFilename: 'polyfills.legacy.js', 72 | 73 | /** 74 | * RegExp patterns of assets to exclude 75 | * @default [] 76 | */ 77 | exclude: [] 78 | }; 79 | 80 | export default class OptimizePlugin { 81 | /** 82 | * @param {Partial?} [options] 83 | */ 84 | constructor (options, webpack = defaultWebpack) { 85 | this.webpack = webpack; 86 | this.options = Object.assign({}, options || {}); 87 | for (const i in DEFAULT_OPTIONS) { 88 | if (this.options[i] == null) this.options[i] = DEFAULT_OPTIONS[i]; 89 | } 90 | 91 | // const { concurrency } = options; 92 | // const workerPath = require.resolve('./worker'); 93 | // if (concurrency === 0 || concurrency === false) { 94 | // this.workerPool = new MockWorkerPool({ workerPath }); 95 | // } 96 | // else { 97 | // this.workerPool = new WorkerPool({ workerPath, concurrency }); 98 | // } 99 | 100 | this.rollupCache = { 101 | modules: [] 102 | }; 103 | 104 | /** @type {Map>} */ 105 | this.polyfillsCache = new Map(); 106 | } 107 | 108 | isWebpack4 () { 109 | return this.webpack.version[0] === '4'; 110 | } 111 | 112 | isWebpack5 () { 113 | return this.webpack.version[0] === '5'; 114 | } 115 | 116 | serializeOptions () { 117 | return this._serialized || (this._serialized = JSON.stringify(this.options)); 118 | } 119 | 120 | async optimize (compiler, compilation, chunkFiles) { 121 | const cwd = compiler.context; 122 | const { timings, start, end } = createPerformanceTimings(); 123 | 124 | const options = { 125 | corejsVersion: getCorejsVersion(), 126 | minify: this.options.minify, 127 | downlevel: this.options.downlevel, 128 | modernize: this.options.modernize, 129 | timings: this.options.verbose 130 | }; 131 | 132 | const processing = new WeakMap(); 133 | const chunkAssets = Array.from(compilation.additionalChunkAssets || []); 134 | const files = [...chunkFiles, ...chunkAssets] 135 | .filter((asset) => { 136 | for (const pattern of this.options.exclude) { 137 | if (pattern.test(asset)) { 138 | return false; 139 | } 140 | } 141 | return true; 142 | }); 143 | 144 | start('Optimize Assets'); 145 | let transformed; 146 | try { 147 | transformed = await Promise.all(files.map(file => { 148 | // ignore non-JS files 149 | if (!file.match(/\.m?[jt]sx?$/i)) return undefined; 150 | const asset = compilation.assets[file]; 151 | let pending = processing.get(asset); 152 | if (pending) return pending; 153 | 154 | let source, map; 155 | if (this.options.sourceMap && asset.sourceAndMap) { 156 | ({ source, map } = asset.sourceAndMap()); 157 | } else { 158 | source = asset.source(); 159 | } 160 | 161 | const original = { file, source, map, options }; 162 | // @ts-ignore-next 163 | const result = this.workerPool.enqueue(original); 164 | pending = result.then(this.buildResultSources.bind(this, original)).catch(console.error); 165 | processing.set(asset, pending); 166 | 167 | const t = ` └ ${file}`; 168 | start(t); 169 | result.then(r => { 170 | for (const entry of r.timings) { 171 | // entry.name = ' ' + entry.name; 172 | entry.depth = 2; 173 | timings.push(entry); 174 | } 175 | end(t); 176 | }); 177 | 178 | return pending; 179 | })); 180 | } catch (e) { 181 | console.log('errored out during transformation ', e); 182 | throw e; 183 | } 184 | 185 | end('Optimize Assets'); 186 | 187 | const allPolyfills = new Set(); 188 | const polyfillReasons = new Map(); 189 | transformed.filter(Boolean).forEach(({ file, modern, legacyFile, legacy, polyfills }, index) => { 190 | for (const p of polyfills) { 191 | allPolyfills.add(p); 192 | let reasons = polyfillReasons.get(p); 193 | if (!reasons) polyfillReasons.set(p, reasons = []); 194 | reasons.push(legacyFile); 195 | } 196 | 197 | compilation.assets[file] = modern; 198 | if (legacy) { 199 | compilation.assets[legacyFile] = legacy; 200 | } else { 201 | // @todo is this actually necessary or desirable? 202 | // should it be ReplaceSource/RawSource with an empty value? 203 | delete compilation.assets[legacyFile]; 204 | } 205 | }); 206 | 207 | const polyfillsFilename = this.options.polyfillsFilename || 'polyfills.legacy.js'; 208 | const polyfills = Array.from(allPolyfills); 209 | let polyfillsAsset; 210 | 211 | if (polyfills.length) { 212 | start('Bundle Polyfills'); 213 | polyfillsAsset = await this.generatePolyfillsChunkCached(polyfills, cwd, polyfillsFilename, timings); 214 | compilation.assets[polyfillsFilename] = polyfillsAsset; 215 | end('Bundle Polyfills'); 216 | } else { 217 | delete compilation.assets[polyfillsFilename]; 218 | } 219 | 220 | timings.sort((t1, t2) => t1.start - t2.start); 221 | if (this.options.verbose) { 222 | await this.showOutputSummary(timings, polyfills, polyfillReasons, polyfillsAsset); 223 | } 224 | } 225 | 226 | async generatePolyfillsChunkCached (polyfills, cwd, polyfillsFilename, timings) { 227 | const polyfillsKey = polyfills.join('\n'); 228 | 229 | let generatePolyfills = this.polyfillsCache.get(polyfillsKey); 230 | if (!generatePolyfills) { 231 | generatePolyfills = this.generatePolyfillsChunk(polyfills, cwd, timings); 232 | this.polyfillsCache.set(polyfillsKey, generatePolyfills); 233 | } 234 | 235 | const output = await generatePolyfills; 236 | 237 | return new SourceMapSource( 238 | output.code, 239 | polyfillsFilename, 240 | // @ts-ignore 241 | output.map 242 | ); 243 | } 244 | 245 | /** 246 | * @todo Write cached polyfills chunk to disk 247 | */ 248 | async generatePolyfillsChunk (polyfills, cwd, timings) { 249 | const ENTRY = '\0entry'; 250 | 251 | const entryContent = polyfills.reduce((str, p) => `${str}\nimport "${p.replace(/\.js$/, '')}";`, ''); 252 | 253 | const COREJS = require.resolve('core-js/package.json').replace('package.json', ''); 254 | const isCoreJsPath = /(?:^|\/)core-js\/(.+)$/; 255 | const nonCoreJsPolyfills = polyfills.filter(p => !/(core-js|regenerator-runtime)/.test(p)); 256 | 257 | if (timings && nonCoreJsPolyfills.length) { 258 | console.log(` Bundling ${nonCoreJsPolyfills.length} unrecognized polyfills.`); 259 | } 260 | 261 | const polyfillsBundle = await rollup({ 262 | cache: this.rollupCache, 263 | context: 'window', 264 | perf: !!timings, 265 | input: ENTRY, 266 | treeshake: { 267 | propertyReadSideEffects: false, 268 | tryCatchDeoptimization: false, 269 | unknownGlobalSideEffects: false 270 | }, 271 | plugins: [ 272 | { 273 | name: 'entry', 274 | resolveId: id => id === ENTRY ? id : null, 275 | load: id => id === ENTRY ? entryContent : null 276 | }, 277 | { 278 | name: 'core-js', 279 | resolveId (id) { 280 | if (/^regenerator-runtime(\/|$)/.test(id)) { 281 | return require.resolve('regenerator-runtime/runtime'); 282 | } 283 | const m = id.match(isCoreJsPath); 284 | if (m && !/\.js$/.test(id)) { 285 | return COREJS + m[1] + '.js'; 286 | } 287 | return null; 288 | }, 289 | load (id) { 290 | const m = id.match(isCoreJsPath); 291 | if (m && id.indexOf('?') === -1) { 292 | return fs.readFile(COREJS + m[1], 'utf-8'); 293 | } 294 | return null; 295 | } 296 | }, 297 | // coreJsPlugin(), 298 | commonjsPlugin({ 299 | // ignoreGlobal: true, 300 | sourceMap: false 301 | }), 302 | nonCoreJsPolyfills.length && nodeResolvePlugin({ 303 | dedupe: nonCoreJsPolyfills, 304 | only: nonCoreJsPolyfills, 305 | preferBuiltins: false 306 | }), 307 | // { 308 | // name: 'babel', 309 | // renderChunk (source) { 310 | // return require('@babel/core').transformAsync(source, { 311 | // sourceMaps: false, 312 | // minified: true, 313 | // shouldPrintComment: () => false, 314 | // presets: [ 315 | // require('babel-preset-modernize') 316 | // ] 317 | // }); 318 | // } 319 | // }, 320 | this.options.minify 321 | ? ( 322 | rollupPluginTerserSimple() 323 | ) 324 | : ( 325 | rollupPluginStripComments() 326 | ) 327 | ].filter(Boolean) 328 | }); 329 | this.setRollupCache(polyfillsBundle.cache); 330 | 331 | const result = await polyfillsBundle.generate({ 332 | exports: 'none', 333 | externalLiveBindings: false, 334 | freeze: false, 335 | compact: true, 336 | format: 'iife', 337 | sourcemap: false, 338 | strict: false 339 | }); 340 | const output = result.output[0]; 341 | 342 | // If verbose logging is enabled, bubble up some useful Rollup time information 343 | if (timings) { 344 | const times = polyfillsBundle.getTimings(); 345 | const add = (name, timing) => { 346 | const t = times[timing]; 347 | if (t) timings.push({ depth: 2, name, duration: t[0] }); 348 | }; 349 | add('parse', '## parse modules'); 350 | add('node-resolve', '- plugin 2 (node-resolve) - resolveId (async)'); 351 | add('generate', '# GENERATE'); 352 | } 353 | 354 | return output; 355 | } 356 | 357 | // yes I did this to fix TS inference 358 | setRollupCache (cache) { 359 | this.rollupCache = cache; 360 | } 361 | 362 | /** @todo move to helper file */ 363 | async showOutputSummary (timings, polyfills, polyfillReasons, polyfillsAsset) { 364 | let totalTime = 0; 365 | let timingsStr = ''; 366 | for (const entry of timings) { 367 | totalTime += entry.duration; 368 | // timingsStr += `\n ${(' ' + (entry.duration || '- ')).substr(-6)}ms: ${entry.name}`; 369 | timingsStr += `\n ${new Array(entry.depth || 1).join(' ')}${String(entry.duration | 0 || '- ').padStart(6, ' ')}ms: ${entry.name}`; 370 | } 371 | 372 | polyfills = polyfills.map(polyfill => { 373 | const reasons = polyfillReasons.get(polyfill); 374 | return { polyfill, reasons, reasonsKey: reasons.join('\n') }; 375 | }); 376 | polyfills.sort((p1, p2) => p1.reasonsKey.localeCompare(p2.reasonsKey)); 377 | 378 | const serializeReasons = (reasons) => { 379 | if (reasons.length === 1) return reasons[0]; 380 | if (reasons.length > 3) { 381 | return `${reasons[0]}, ${reasons[1]} and ${reasons.length - 2} others`; 382 | } 383 | reasons = reasons.slice(); 384 | const last = reasons.pop(); 385 | return reasons.join(', ') + ' and ' + last; 386 | }; 387 | 388 | let lastReasonsKey; 389 | let polyfillsStr = polyfills.reduce((str, { polyfill, reasons, reasonsKey }) => { 390 | if (reasonsKey !== lastReasonsKey) { 391 | str = str.replace(/├.*?$/, '└'); 392 | str += `\n└ Used by ${serializeReasons(reasons)}:`; 393 | lastReasonsKey = reasonsKey; 394 | } 395 | str += `\n ├ ${polyfill}`; 396 | return str; 397 | }, ''); 398 | polyfillsStr = polyfillsStr.replace(/├(.*?)$/, '└$1'); 399 | 400 | const preamble = `[${NAME}] Completed in ${totalTime | 0}ms.${timingsStr}\n`; 401 | 402 | if (!polyfillsAsset) { 403 | console.log(preamble + 'No polyfills bundle was created.'); 404 | return; 405 | } 406 | 407 | const polyfillsSize = polyfillsAsset ? (await util.promisify(gzip)(polyfillsAsset.source())).byteLength : 0; 408 | const polyfillsSizeStr = (polyfillsSize / 1000).toPrecision(3) + 'kB'; 409 | 410 | console.log( 411 | preamble + 412 | `${polyfillsAsset._name} is ${polyfillsSizeStr} and bundles ${polyfills.length} polyfills:${polyfillsStr}` 413 | ); 414 | } 415 | 416 | /** @todo Support other file extensions */ 417 | toLegacyFilename (file) { 418 | let out = file.replace(/(\.m?[jt]sx?)$/g, '.legacy$1'); 419 | if (out === file) { 420 | // this will create `foo.js.legacy.js`, but it's the best we can hope for. 421 | out += '.legacy.js'; 422 | } 423 | return out; 424 | } 425 | 426 | buildResultSources (original, result) { 427 | const file = original.file; 428 | const modern = this.buildFile(original, result.modern); 429 | let legacy, legacyFile; 430 | if (result.legacy) { 431 | legacyFile = this.toLegacyFilename(file); 432 | legacy = this.buildFile(original, result.legacy, legacyFile); 433 | } 434 | return { file, legacyFile, modern, legacy, polyfills: result.polyfills }; 435 | } 436 | 437 | buildFile (original, result, name) { 438 | if (result.map) { 439 | return new SourceMapSource( 440 | result.source, 441 | name || original.file, 442 | result.map, 443 | original.source, 444 | original.map 445 | ); 446 | } 447 | // @todo use LineToLineMappedSource as the fallback? 448 | return new RawSource(result.source); 449 | } 450 | 451 | // modify chunkHash (webpack 4 & 5) 452 | updateChunkHash (compilation) { 453 | const updateWithHash = (chunk, hash) => { 454 | hash.update(NAME); 455 | hash.update(this.serializeOptions()); 456 | }; 457 | 458 | if (this.isWebpack4()) { 459 | compilation.mainTemplate.hooks.hashForChunk.tap(NAME, updateWithHash.bind(null, null)); 460 | compilation.chunkTemplate.hooks.hashForChunk.tap(NAME, updateWithHash.bind(null, null)); 461 | } else { 462 | // @ts-ignore 463 | this.webpack.javascript.JavascriptModulesPlugin.getCompilationHooks(compilation).chunkHash.tap(NAME, updateWithHash); 464 | } 465 | } 466 | 467 | apply (compiler) { 468 | this.workerPool = new WorkerPool({ 469 | workerPath: require.resolve('./worker'), 470 | concurrency: this.options.concurrency 471 | }); 472 | 473 | compiler.hooks.compilation.tap(NAME, compilation => { 474 | this.updateChunkHash(compilation); 475 | 476 | if (this.isWebpack5()) { 477 | compilation.hooks.processAssets.tapPromise({ 478 | name: NAME, 479 | stage: this.webpack.Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE 480 | }, (assets) => { 481 | const chunkFiles = Object.keys(assets); 482 | 483 | return this.optimize(compiler, compilation, chunkFiles); 484 | }); 485 | } else { 486 | compilation.hooks.optimizeChunkAssets.tapPromise(NAME, (chunks) => { 487 | const chunkFiles = Array.from(chunks).reduce( 488 | (acc, chunk) => acc.concat(Array.from(chunk.files || [])), 489 | [] 490 | ); 491 | 492 | return this.optimize(compiler, compilation, chunkFiles); 493 | }); 494 | } 495 | }); 496 | } 497 | } 498 | -------------------------------------------------------------------------------- /src/lib/rollup-plugin-strip-comments.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import MagicString from 'magic-string'; 18 | 19 | export default function rollupPluginStripComments () { 20 | return { 21 | name: 'simple-minify', 22 | renderChunk (source) { 23 | const comments = []; 24 | this.parse(source, { onComment: comments }); 25 | if (comments.length) { 26 | const s = new MagicString(source); 27 | for (const comment of comments) { 28 | s.remove(comment.start, comment.end).trim(); 29 | } 30 | return { code: s.toString(), map: null }; 31 | } 32 | return null; 33 | } 34 | }; 35 | } 36 | -------------------------------------------------------------------------------- /src/lib/rollup-plugin-terser-simple.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as terser from 'terser'; 18 | import { toBabelMap } from './util'; 19 | 20 | export default function rollupPluginTerserSimple () { 21 | return { 22 | name: 'rollup-plugin-terser-simple', 23 | async renderChunk (source, chunk, options) { 24 | const { code, map } = await terser.minify(source, { 25 | compress: { 26 | global_defs: { 27 | 'typeof self': '"object"', 28 | 'globalThis': 'undefined' 29 | }, 30 | pure_getters: true, 31 | unsafe: true 32 | }, 33 | mangle: true, 34 | toplevel: true, 35 | sourceMap: options.sourcemap && { 36 | filename: chunk.fileName 37 | } 38 | }); 39 | return { 40 | code, 41 | map: toBabelMap(map) 42 | }; 43 | } 44 | }; 45 | } 46 | -------------------------------------------------------------------------------- /src/lib/transform-change-webpack-urls.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Detects and analyzes a Webpack "entry" bundle, allowing String transformations on its internal chunk URL map. 19 | * 20 | * @example 21 | * { 22 | * plugins: [ 23 | * ['./transform-change-webpack-urls', { 24 | * pattern: /\.js$/, 25 | * replacement: '.legacy.js' 26 | * }] 27 | * ] 28 | * } 29 | * 30 | * @see https://astexplorer.net/#/gist/0995f8452cfa62d797a2a778a3442b65/2e588cc89829971495ca8e38905bb5581a23cf5d 31 | */ 32 | 33 | /** @typedef NodePath @type {import('@babel/core').NodePath} */ 34 | 35 | export default function ({ types: t }) { 36 | function unwrap (path) { 37 | if (t.isExpressionStatement(path)) { 38 | return unwrap(path.get('expression')); 39 | } 40 | if (t.isUnaryExpression(path) && path.node.operator === '!') { 41 | return unwrap(path.get('argument')); 42 | } 43 | return path; 44 | } 45 | 46 | /** 47 | * Attempt to parse a path and find a webpack Bootstrap function, if so returning a description. 48 | * @param {NodePath} path 49 | */ 50 | function getWebpackBootstrap (path) { 51 | path = unwrap(path); 52 | if (!t.isCallExpression(path)) return false; 53 | const factory = path.get('callee'); 54 | if (!t.isFunctionExpression(factory)) return false; 55 | const bootstrap = parseWebpackBootstrap(factory); 56 | if (!bootstrap || !bootstrap.confident) return false; 57 | const args = path.get('arguments'); 58 | bootstrap.modules = getWebpackModules(args); 59 | return bootstrap; 60 | } 61 | 62 | /** 63 | * Verify that a Path is a "bootstrap" function, which is Webpack's module registry and loader implementation. 64 | * @param {NodePath} path 65 | */ 66 | function parseWebpackBootstrap (path) { 67 | const bootstrap = { 68 | confident: false, 69 | /** @type {NodePath} */ 70 | factory: null, 71 | /** @type {NodePath} */ 72 | urlMap: null 73 | }; 74 | 75 | // // TODO: this is silly and should use binding lookup + parent checks. Something like: 76 | // const bindings = path.get('body').scope.bindings; 77 | // console.log(path.get('body').scope.hasGlobal('window')); 78 | // for (let name in bindings) { 79 | // const binding = bindings[name]; 80 | // if (1){} 81 | // } 82 | // bindings.some(b => { 83 | // b.referencePaths.some(p => t.isMemberExpression(p.parent) && t.isAssignmentExpression(p.parentPath.parent) && p.parent.property.name === 'oe') 84 | // }); 85 | 86 | const identifiers = {}; 87 | path.get('body').traverse({ 88 | MemberExpression (p) { 89 | // Find the script loader function (indicated by the presence of document.createElement("script"). 90 | // Note that this check *does* traverse into nested functions. 91 | if (t.matchesPattern(p.node, 'document.createElement') && t.isCallExpression(p.parent) && t.isStringLiteral(p.parent.arguments[0], { value: 'script' })) { 92 | const id = p.parentPath.parent.id.name; 93 | p.scope.getBinding(id).referencePaths.forEach(p => { 94 | // Find script.src= assignment mapping: 95 | const parent = p.parentPath; 96 | if (t.isMemberExpression(parent) && t.isIdentifier(parent.node.property, { name: 'src' }) && t.isAssignmentExpression(parent.parent)) { 97 | // Find the assigned value: s.src = X 98 | let expr = parent.parentPath.get('right').resolve(); 99 | // It might be a function call: 100 | if (t.isCallExpression(expr)) { 101 | expr = expr.get('callee').resolve(); 102 | } 103 | // That function call might be an IIFE (it generally is): 104 | if (t.isFunction(expr)) { 105 | expr = expr.get('body.body').filter(t.isReturnStatement)[0]; 106 | if (expr) expr = expr.get('argument'); 107 | } 108 | // Store it for later manipulation 109 | bootstrap.urlMap = expr; 110 | } 111 | }); 112 | return; 113 | } 114 | 115 | // Detect extensions to Webpack's main namespace. 116 | // They're assignments to properties on a local binding, but we don't yet know which one. 117 | // We ignore nested functions, and only look at assignments. 118 | if (p.scope !== path.scope || !t.isAssignmentExpression(p.parent)) { 119 | return; 120 | } 121 | if (t.isIdentifier(p.node.object) && t.isIdentifier(p.node.property)) { 122 | let a = identifiers[p.node.object.name]; 123 | if (!a) a = identifiers[p.node.object.name] = {}; 124 | a[p.node.property.name] = p; 125 | } 126 | } 127 | }); 128 | // shallowWalk(path.get('body'), p => { 129 | // if (!t.isMemberExpression(p)) return; 130 | // if (t.isIdentifier(p.node.object) && t.isIdentifier(p.node.property)) { 131 | // let a = identifiers[p.node.object.name]; 132 | // if (!a) a = identifiers[p.node.object.name] = {}; 133 | // a[p.node.property.name] = true; 134 | // } 135 | // }); 136 | 137 | // Check if we found a binding with properties that signify a Webpack namespace object: 138 | for (const a in identifiers) { 139 | const v = identifiers[a]; 140 | // Look for `__webpack_public_path__`, `__webpack_modules__` and an onerror handler: 141 | if (v.p && v.c && v.oe) { 142 | bootstrap.confident = true; 143 | bootstrap.factory = path; 144 | // might be useful later 145 | bootstrap.api = v; 146 | break; 147 | } 148 | } 149 | 150 | // Regardless of structure, a `window.webpackJsonp` reference means this is a webpack bootstrap function: 151 | if (identifiers.window && identifiers.window.webpackJsonp) { 152 | bootstrap.confident = true; 153 | bootstrap.factory = path; 154 | bootstrap.webpackJsonp = identifiers.window.webpackJsonp; 155 | } 156 | 157 | return bootstrap; 158 | } 159 | 160 | function getWebpackModules (list) { 161 | const modules = []; 162 | const mod = m => { 163 | if (t.isFunctionExpression(m)) { 164 | modules.push(m); 165 | } else { 166 | throw Error('Not a webpack bundle'); 167 | } 168 | }; 169 | list.forEach(m => { 170 | if (t.isArrayExpression(m)) { 171 | m.get('elements').forEach(mod); 172 | } else { 173 | mod(m); 174 | } 175 | }); 176 | return modules; 177 | } 178 | 179 | // function shallowWalk(path, callback) { 180 | // if (Array.isArray(path)) { 181 | // for (let i=0; i { 220 | const bootstrap = getWebpackBootstrap(expr); 221 | if (!bootstrap) return; 222 | 223 | // Replace the template part containing ".js" with ".module.js" 224 | if (bootstrap.urlMap) { 225 | bootstrap.urlMap.traverse({ 226 | StringLiteral (s) { 227 | if (/\.js$/.test(s.node.value)) { 228 | s.replaceWith(t.stringLiteral(s.node.value.replace(pattern, replacement))); 229 | s.stop(); 230 | } 231 | } 232 | }); 233 | } 234 | }); 235 | } 236 | } 237 | }; 238 | } 239 | -------------------------------------------------------------------------------- /src/lib/transform-extract-polyfills.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | export default function () { 18 | return { 19 | name: 'transform-extract-polyfills', 20 | visitor: { 21 | ImportDeclaration (path, state) { 22 | state.opts.onPolyfill(path.node.source.value); 23 | path.remove(); 24 | } 25 | } 26 | }; 27 | } 28 | -------------------------------------------------------------------------------- /src/lib/util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** 18 | * Convert a Babel-style SourceMap to Terser-style, parsing if necessary. 19 | * @param {(import('@babel/core').BabelFileResult)['map']|string|null} map 20 | * @return {import('source-map').RawSourceMap|null} map 21 | * @todo This should be deleted, as it's exclusively to make TypeScript happy. 22 | */ 23 | export function toTerserMap (map) { 24 | if (typeof map === 'string') map = JSON.parse(map); 25 | return typeof map === 'object' && map ? { 26 | ...map, 27 | version: String(map.version) 28 | } : null; 29 | } 30 | 31 | /** 32 | * Convert a Terser-style SourceMap to Babel-style, parsing if necessary. 33 | * @param {import('source-map').RawSourceMap|string|null} map 34 | * @returns {(import('@babel/core').BabelFileResult)['map']|null} 35 | * @todo This should be deleted, as it's exclusively to make TypeScript happy. 36 | */ 37 | export function toBabelMap (map) { 38 | if (typeof map === 'string') map = JSON.parse(map); 39 | return typeof map === 'object' && map ? { 40 | file: '', 41 | ...map, 42 | version: parseInt(map.version, 10) 43 | } : null; 44 | } 45 | 46 | const DEFAULT_COREJS_VERSION = 2; 47 | 48 | let corejsVersion; 49 | 50 | /** 51 | * Get the user's installed version of core-js 52 | * @returns {number} 53 | */ 54 | export function getCorejsVersion () { 55 | if (!corejsVersion) { 56 | try { 57 | // @ts-ignore 58 | corejsVersion = parseInt(require('core-js/package.json').version, 10); 59 | console.log(`[OptimizePlugin] Detected core-js version ${corejsVersion}`); 60 | } catch (e) { 61 | console.warn( 62 | `[OptimizePlugin] Unable to detect installed version of core-js. Assuming core-js@${DEFAULT_COREJS_VERSION}.` 63 | ); 64 | corejsVersion = DEFAULT_COREJS_VERSION; 65 | } 66 | } 67 | return corejsVersion; 68 | } 69 | 70 | export function createPerformanceTimings () { 71 | const timings = []; 72 | 73 | const start = name => { 74 | timings.push({ name, start: Date.now() }); 75 | }; 76 | 77 | const end = name => { 78 | for (const entry of timings) { 79 | if (entry.name === name) { 80 | entry.end = Date.now(); 81 | entry.duration = entry.end - entry.start; 82 | return; 83 | } 84 | } 85 | }; 86 | 87 | return { 88 | timings, 89 | start, 90 | end 91 | }; 92 | } 93 | -------------------------------------------------------------------------------- /src/lib/worker-pool.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import Worker from 'jest-worker'; 18 | 19 | export class WorkerPool { 20 | constructor ({ workerPath, concurrency }) { 21 | // concurrency = 0; 22 | if (concurrency === false || concurrency === 0) { 23 | return { enqueue: t => require(workerPath).process(t) }; 24 | } 25 | 26 | const worker = new Worker(workerPath, { 27 | enableWorkerThreads: false, 28 | numWorkers: concurrency 29 | }); 30 | let pending = 0; 31 | let timer; 32 | function check () { 33 | clearTimeout(timer); 34 | if (--pending === 0) { 35 | timer = setTimeout(() => { 36 | worker.end(); 37 | }, 10); 38 | } 39 | } 40 | worker.enqueue = task => { 41 | clearTimeout(timer); 42 | const p = worker.process(task); 43 | pending++; 44 | p.then(check); 45 | return p; 46 | }; 47 | return worker; 48 | } 49 | } 50 | 51 | /* 52 | import os from 'os'; 53 | import Worker from 'jest-worker'; 54 | 55 | export class WorkerPool { 56 | constructor ({ workerPath, concurrency }) { 57 | this.concurrency = Math.max(1, Math.round(concurrency || os.cpus().length || 1)); 58 | this.runInBand = concurrency === 0 || concurrency === false; 59 | this.workerPath = workerPath; 60 | this.queue = []; 61 | this.workers = []; 62 | this.freeWorkers = []; 63 | } 64 | 65 | // terminateAll () { 66 | // let worker; 67 | // while ((worker = this.workers.pop())) { 68 | // worker.terminate(); 69 | // } 70 | // } 71 | 72 | cleanup () { 73 | clearTimeout(this.cleanupTimer); 74 | const worker = this.getFreeWorker(); 75 | if (worker) { 76 | worker.end(); 77 | this.cleanupTimer = setTimeout(this.cleanup.bind(this), 100); 78 | } 79 | } 80 | 81 | getFreeWorker () { 82 | return this.freeWorkers.pop(); 83 | } 84 | 85 | addWorker () { 86 | if (this.workers.length >= this.concurrency) return; 87 | const worker = this.runInBand ? require(this.workerPath) : new Worker(this.workerPath, { 88 | enableWorkerThreads: true, 89 | numWorkers: 1 90 | // maxRetries: 0 91 | }); 92 | this.workers.push(worker); 93 | return worker; 94 | } 95 | 96 | enqueue (item) { 97 | return new Promise((resolve, reject) => { 98 | if (this.queue.push({ item, resolve, reject }) === 1) { 99 | this.process(); 100 | } 101 | }); 102 | } 103 | 104 | async process () { 105 | clearTimeout(this.cleanupTimer); 106 | if (!this.queue.length) { 107 | this.cleanupTimer = setTimeout(this.cleanup.bind(this), 100); 108 | return; 109 | } 110 | const worker = this.getFreeWorker() || this.addWorker(); 111 | if (!worker) { 112 | console.log('queue full'); 113 | return; 114 | } 115 | const { item, resolve, reject } = this.queue.pop(); 116 | try { 117 | const result = await worker.process(item); 118 | resolve(result); 119 | } catch (e) { 120 | reject(e); 121 | } 122 | this.freeWorkers.unshift(worker); 123 | this.process(); 124 | } 125 | } 126 | 127 | // class MockWorkerPool extends WorkerPool { 128 | // constructor(options) { 129 | // super({ 130 | // ...options, 131 | // concurrency: 1 132 | // }); 133 | // } 134 | // getFreeWorker() { 135 | // return require(this.workerPath); 136 | // } 137 | // } 138 | */ 139 | -------------------------------------------------------------------------------- /src/worker.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import * as terser from 'terser'; 18 | import babel from '@babel/core'; 19 | import transformWebpackUrls from './lib/transform-change-webpack-urls'; 20 | import extractPolyfills from './lib/transform-extract-polyfills'; 21 | import { toBabelMap, toTerserMap, createPerformanceTimings } from './lib/util'; 22 | 23 | const NAME = 'OptimizePlugin'; 24 | 25 | const TERSER_CACHE = {}; 26 | 27 | const noopTimings = { timings: [], start: n => {}, end: n => {} }; 28 | 29 | /** 30 | * @param {object} $0 31 | * @param {string} $0.file 32 | * @param {string} $0.source 33 | * @param {string|object} $0.map 34 | * @param {object} [$0.options] 35 | * @param {boolean} [$0.options.timings = false] 36 | * @param {boolean} [$0.options.minify = false] 37 | * @param {boolean} [$0.options.downlevel = false] 38 | * @param {boolean} [$0.options.modernize = false] 39 | * @param {number} [$0.options.corejsVersion] 40 | */ 41 | export async function process ({ file, source, map, options = {} }) { 42 | const { timings, start, end } = options.timings ? createPerformanceTimings() : noopTimings; 43 | const { minify, downlevel, modernize } = options; 44 | 45 | const polyfills = new Set(); 46 | let legacy; 47 | 48 | const outputOptions = { 49 | compact: minify, 50 | minified: minify, 51 | // envName: minify ? 'production' : 'development', 52 | comments: minify ? false : undefined, 53 | generatorOpts: { 54 | concise: true 55 | } 56 | }; 57 | 58 | start('modern'); 59 | const modern = await babel.transformAsync(source, { 60 | configFile: false, 61 | babelrc: false, 62 | filename: file, 63 | inputSourceMap: map, 64 | sourceMaps: true, 65 | sourceFileName: file, 66 | sourceType: 'module', 67 | envName: 'modern', 68 | // ast: true, 69 | presets: [ 70 | ['@babel/preset-env', { 71 | loose: true, 72 | modules: false, 73 | bugfixes: true, 74 | targets: { 75 | esmodules: true 76 | }, 77 | // corejs: options.corejsVersion, 78 | useBuiltIns: false 79 | }], 80 | modernize && ['babel-preset-modernize', { 81 | loose: true, 82 | webpack: true 83 | }] 84 | ].filter(Boolean), 85 | ...outputOptions, 86 | caller: { 87 | supportsStaticESM: true, 88 | name: NAME + '-modern' 89 | } 90 | }); 91 | end('modern'); 92 | 93 | if (minify) { 94 | start('modern-minify'); 95 | const minified = await terser.minify(modern.code, { 96 | // Enables shorthand properties in objects and object patterns: 97 | ecma: 2017, 98 | module: false, 99 | nameCache: TERSER_CACHE, 100 | // sourceMap: true, 101 | sourceMap: { 102 | content: toTerserMap(modern.map) 103 | }, 104 | compress: { 105 | global_defs: { 106 | MODERN_MODE: true, 107 | 'process.env.NODE_ENV': global.process.env.NODE_ENV || 'production' 108 | } 109 | }, 110 | // Fix Safari 10 issues 111 | // ({a}) --> ({a:a}) 112 | // !await a --> !(await a) 113 | safari10: true, 114 | mangle: { 115 | toplevel: true 116 | // safari10: true 117 | // properties: { 118 | // regex: /./ 119 | // } 120 | } 121 | }); 122 | 123 | modern.code = minified.code; 124 | modern.map = toBabelMap(minified.map); 125 | 126 | // @todo this means modern.ast is now out-of-sync with modern.code 127 | // can this work? or do we need to run Terser separately for modern/legacy? 128 | end('modern-minify'); 129 | } 130 | 131 | if (downlevel) { 132 | start('legacy'); 133 | // legacy = await babel.transformFromAstAsync(modern.ast, modern.code, { 134 | legacy = await babel.transformAsync(modern.code, { 135 | configFile: false, 136 | babelrc: false, 137 | filename: file, 138 | inputSourceMap: modern.map, 139 | sourceMaps: true, 140 | sourceFileName: file, 141 | sourceType: 'module', 142 | envName: 'legacy', 143 | presets: [ 144 | ['@babel/preset-env', { 145 | loose: true, 146 | modules: false, 147 | // corejs: 3, 148 | corejs: options.corejsVersion, 149 | useBuiltIns: 'usage' 150 | }] 151 | ], 152 | plugins: [ 153 | [transformWebpackUrls, { 154 | pattern: /\.js$/, 155 | replacement: '.legacy.js' 156 | }], 157 | [extractPolyfills, { 158 | onPolyfill (specifier) { 159 | polyfills.add(specifier); 160 | } 161 | }] 162 | ], 163 | ...outputOptions, 164 | caller: { 165 | supportsStaticESM: false, 166 | name: NAME + '-legacy' 167 | } 168 | }); 169 | end('legacy'); 170 | } 171 | 172 | return { 173 | modern: sanitizeResult(modern), 174 | legacy: legacy && sanitizeResult(legacy), 175 | polyfills: Array.from(polyfills), 176 | timings 177 | }; 178 | } 179 | 180 | function sanitizeResult (result) { 181 | return { source: result.code, map: result.map }; 182 | } 183 | -------------------------------------------------------------------------------- /test/_sucrase-loader.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const path = require('path'); 18 | const { transform } = require('sucrase'); 19 | 20 | module.exports = function (source) { 21 | const name = path.relative(process.cwd(), this.resource); 22 | const isTs = /\.tsx?$/.test(name); 23 | const { code } = transform(source, { 24 | transforms: isTs ? ['typescript', 'jsx'] : ['jsx'], 25 | production: true 26 | }); 27 | return code; 28 | }; 29 | -------------------------------------------------------------------------------- /test/_util.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import { promises as fs } from 'fs'; 18 | import path from 'path'; 19 | import { gzip as gzipSync } from 'zlib'; 20 | import util from 'util'; 21 | import CleanPlugin from 'clean-webpack-plugin'; 22 | 23 | export function sleep (ms) { 24 | return new Promise(resolve => setTimeout(resolve, ms)); 25 | } 26 | 27 | const gzip = util.promisify(gzipSync); 28 | const gzipSize = async x => (await gzip(x)).byteLength; 29 | 30 | export async function printSizes (assets, name, console) { 31 | let modernSize = 0; 32 | let legacySize = 0; 33 | const prettyBytes = (size) => { 34 | if (size > 1500) return (size / 1000).toFixed(2) + 'kB'; 35 | return size + 'b'; 36 | }; 37 | const showSize = async (file) => { 38 | const size = await gzipSize(assets[file]); 39 | let str = `\n ${file}: ${prettyBytes(size)}`; 40 | if (file.match(/.legacy/)) { 41 | legacySize += size; 42 | } else { 43 | modernSize += size; 44 | const legacyName = file.replace(/\.js$/, '.legacy.js'); 45 | if (assets[legacyName]) str += await showSize(legacyName); 46 | } 47 | return str; 48 | }; 49 | 50 | let str = `SIZES${name ? ` for ${name}` : ''}:`; 51 | for (const i in assets) { 52 | if (i.match(/.legacy/)) continue; 53 | str += await showSize(i); 54 | } 55 | str += await showSize('polyfills.legacy.js'); 56 | str += `\n> Total: ${prettyBytes(modernSize)} - ${prettyBytes(legacySize - modernSize)} (${Math.round((legacySize - modernSize) / legacySize * 100)}%) smaller in modern browsers.`; 57 | console.log(str); 58 | } 59 | 60 | export function runWebpack (webpack, fixture, { output = {}, plugins = [], module = {}, resolve = {}, ...config } = {}, console) { 61 | return run(callback => webpack({ 62 | mode: 'production', 63 | devtool: false, 64 | context: path.resolve(__dirname, 'fixtures', fixture), 65 | entry: './entry.js', 66 | output: { 67 | publicPath: 'dist/', 68 | path: path.resolve(__dirname, 'fixtures', fixture, 'dist'), 69 | ...(output || {}) 70 | }, 71 | module: { 72 | ...module, 73 | rules: [].concat(module.rules || []) 74 | }, 75 | resolve, 76 | plugins: [ 77 | new CleanPlugin([ 78 | path.resolve(__dirname, 'fixtures', fixture, 'dist', '**') 79 | ]) 80 | ].concat(plugins || []), 81 | ...config 82 | }, callback), console); 83 | } 84 | 85 | export function watchWebpack (webpack, fixture, { output, plugins, context, ...config } = {}, console) { 86 | context = context || path.resolve(__dirname, 'fixtures', fixture); 87 | const compiler = webpack({ 88 | mode: 'production', 89 | context, 90 | entry: './entry.js', 91 | output: { 92 | publicPath: 'dist/', 93 | path: path.resolve(context, 'dist'), 94 | ...(output || {}) 95 | }, 96 | plugins: plugins || [] 97 | }); 98 | compiler.doRun = () => run(compiler.run.bind(compiler), console); 99 | return compiler; 100 | } 101 | 102 | export async function statsWithAssets (stats) { 103 | const assets = Object.keys(stats.compilation.assets); 104 | const basePath = stats.compilation.outputOptions.path; 105 | const contents = {}; 106 | 107 | await Promise.all(assets.map(async (asset) => { 108 | const assetPath = path.join(basePath, asset); 109 | const content = await fs.readFile(assetPath, 'utf8'); 110 | 111 | contents[asset] = content; 112 | })); 113 | 114 | stats.assets = contents; 115 | } 116 | 117 | function run (runner, console) { 118 | return new Promise((resolve, reject) => { 119 | runner(async (err, stats) => { 120 | if (err) return reject(err); 121 | 122 | await statsWithAssets(stats); 123 | 124 | stats.info = stats.toJson({ assets: true, chunks: true }); 125 | 126 | if (stats.hasWarnings()) { 127 | stats.info.warnings.forEach(warning => { 128 | console.warn('Webpack warning: ', warning); 129 | }); 130 | console.warn('\nWebpack build generated ' + stats.info.warnings.length + ' warnings(s), shown above.\n\n'); 131 | } 132 | if (stats.hasErrors()) { 133 | return reject(stats.info.errors.join('\n')); 134 | } 135 | resolve(stats); 136 | }); 137 | }); 138 | } 139 | -------------------------------------------------------------------------------- /test/_webpacks.js: -------------------------------------------------------------------------------- 1 | export const webpacks = [ 2 | [5, require('webpack'), require.resolve('ts-loader')], 3 | [4, require('webpack-4/node_modules/webpack'), require.resolve('webpack-4/node_modules/ts-loader')] 4 | ]; 5 | -------------------------------------------------------------------------------- /test/fixtures/basic/entry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | console.log('hello world'); 18 | 19 | Object.defineProperty(window, 'test', { 20 | value: 'hello world' 21 | }); 22 | -------------------------------------------------------------------------------- /test/fixtures/code-splitting/about.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | import { h } from 'preact'; 17 | 18 | const HTML = ` 19 | This is a lovely website. 20 | More specifically, you're looking at the About page. 21 | `; 22 | 23 | export default function Home () { 24 | return h('pre', { id: 'home', dangerouslySetInnerHTML: { __html: HTML } }); 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/code-splitting/entry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import { h, render } from 'preact'; 18 | import { useState } from 'preact/hooks'; 19 | 20 | function lazy (load) { 21 | let pending, component; 22 | return function Lazy (props) { 23 | const [, render] = useState({}); 24 | if (!pending) { 25 | pending = pending || load() || (() => {}); 26 | if (pending && pending.then) pending.then(c => render(component = c)); 27 | else component = pending; 28 | } 29 | return component ? h(component, props) : null; 30 | }; 31 | } 32 | 33 | const Home = lazy(() => import(/* webpackChunkName:"home" */ './home')); 34 | const Profile = lazy(() => import(/* webpackChunkName:"profile" */ './profile')); 35 | const About = lazy(() => import(/* webpackChunkName:"about" */ './about')); 36 | 37 | function App ({ url }) { 38 | return h('div', { id: 'app' }, 39 | h('h1', { class: 'header' }, 'hello world'), 40 | h(Home, null), 41 | h(About, null), 42 | h(Profile, null) 43 | ); 44 | } 45 | 46 | render(h(App, null), document.body); 47 | -------------------------------------------------------------------------------- /test/fixtures/code-splitting/home.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | import { h } from 'preact'; 17 | 18 | const HTML = ` 19 | Hello and welcome to, the internet. 20 | `; 21 | 22 | export default function Home () { 23 | return h('pre', { id: 'home', dangerouslySetInnerHTML: { __html: HTML } }); 24 | } 25 | -------------------------------------------------------------------------------- /test/fixtures/code-splitting/profile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | import { h } from 'preact'; 17 | 18 | const HTML = ` 19 | Imagine an engaging and graphically impressive profile page here. 20 | Perhaps the profile portrays a specific user, or a koala. 21 | `; 22 | 23 | export default function Home () { 24 | return h('pre', { id: 'profile', dangerouslySetInnerHTML: { __html: HTML } }); 25 | } 26 | -------------------------------------------------------------------------------- /test/fixtures/typescript/entry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import * as util from './util'; 18 | import PinchZoom from './pinch-zoom'; 19 | 20 | console.log(util, PinchZoom); 21 | document.body.appendChild(document.createElement('pinch-zoom')); 22 | -------------------------------------------------------------------------------- /test/fixtures/typescript/pinch-zoom.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | import PointerTracker, { Pointer } from './pointer-tracker'; 18 | // import './styles.css'; 19 | 20 | interface Point { 21 | clientX: number; 22 | clientY: number; 23 | } 24 | 25 | interface ChangeOptions { 26 | /** 27 | * Fire a 'change' event if values are different to current values 28 | */ 29 | allowChangeEvent?: boolean; 30 | } 31 | 32 | interface ApplyChangeOpts extends ChangeOptions { 33 | panX?: number; 34 | panY?: number; 35 | scaleDiff?: number; 36 | originX?: number; 37 | originY?: number; 38 | } 39 | 40 | interface SetTransformOpts extends ChangeOptions { 41 | scale?: number; 42 | x?: number; 43 | y?: number; 44 | } 45 | 46 | type ScaleRelativeToValues = 'container' | 'content'; 47 | 48 | export interface ScaleToOpts extends ChangeOptions { 49 | /** Transform origin. Can be a number, or string percent, eg "50%" */ 50 | originX?: number | string; 51 | /** Transform origin. Can be a number, or string percent, eg "50%" */ 52 | originY?: number | string; 53 | /** Should the transform origin be relative to the container, or content? */ 54 | relativeTo?: ScaleRelativeToValues; 55 | } 56 | 57 | function getDistance(a: Point, b?: Point): number { 58 | if (!b) return 0; 59 | return Math.sqrt((b.clientX - a.clientX) ** 2 + (b.clientY - a.clientY) ** 2); 60 | } 61 | 62 | function getMidpoint(a: Point, b?: Point): Point { 63 | if (!b) return a; 64 | 65 | return { 66 | clientX: (a.clientX + b.clientX) / 2, 67 | clientY: (a.clientY + b.clientY) / 2, 68 | }; 69 | } 70 | 71 | function getAbsoluteValue(value: string | number, max: number): number { 72 | if (typeof value === 'number') return value; 73 | 74 | if (value.trimRight().endsWith('%')) { 75 | return max * parseFloat(value) / 100; 76 | } 77 | return parseFloat(value); 78 | } 79 | 80 | // I'd rather use DOMMatrix/DOMPoint here, but the browser support isn't good enough. 81 | // Given that, better to use something everything supports. 82 | let cachedSvg: SVGSVGElement; 83 | 84 | function getSVG(): SVGSVGElement { 85 | return cachedSvg || (cachedSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg')); 86 | } 87 | 88 | function createMatrix(): SVGMatrix { 89 | return getSVG().createSVGMatrix(); 90 | } 91 | 92 | function createPoint(): SVGPoint { 93 | return getSVG().createSVGPoint(); 94 | } 95 | 96 | const MIN_SCALE = 0.01; 97 | 98 | export default class PinchZoom extends HTMLElement { 99 | // The element that we'll transform. 100 | // Ideally this would be shadow DOM, but we don't have the browser 101 | // support yet. 102 | private _positioningEl?: Element; 103 | // Current transform. 104 | private _transform: SVGMatrix = createMatrix(); 105 | 106 | constructor() { 107 | super(); 108 | 109 | // Watch for children changes. 110 | // Note this won't fire for initial contents, 111 | // so _stageElChange is also called in connectedCallback. 112 | new MutationObserver(() => this._stageElChange()) 113 | .observe(this, { childList: true }); 114 | 115 | // Watch for pointers 116 | const pointerTracker: PointerTracker = new PointerTracker(this, { 117 | start: (pointer, event) => { 118 | // We only want to track 2 pointers at most 119 | if (pointerTracker.currentPointers.length === 2 || !this._positioningEl) return false; 120 | event.preventDefault(); 121 | return true; 122 | }, 123 | move: (previousPointers) => { 124 | this._onPointerMove(previousPointers, pointerTracker.currentPointers); 125 | }, 126 | }); 127 | 128 | this.addEventListener('wheel', event => this._onWheel(event)); 129 | } 130 | 131 | connectedCallback() { 132 | this._stageElChange(); 133 | } 134 | 135 | get x() { 136 | return this._transform.e; 137 | } 138 | 139 | get y() { 140 | return this._transform.f; 141 | } 142 | 143 | get scale() { 144 | return this._transform.a; 145 | } 146 | 147 | /** 148 | * Change the scale, adjusting x/y by a given transform origin. 149 | */ 150 | scaleTo(scale: number, opts: ScaleToOpts = {}) { 151 | let { 152 | originX = 0, 153 | originY = 0, 154 | } = opts; 155 | 156 | const { 157 | relativeTo = 'content', 158 | allowChangeEvent = false, 159 | } = opts; 160 | 161 | const relativeToEl = (relativeTo === 'content' ? this._positioningEl : this); 162 | 163 | // No content element? Fall back to just setting scale 164 | if (!relativeToEl || !this._positioningEl) { 165 | this.setTransform({ scale, allowChangeEvent }); 166 | return; 167 | } 168 | 169 | const rect = relativeToEl.getBoundingClientRect(); 170 | originX = getAbsoluteValue(originX, rect.width); 171 | originY = getAbsoluteValue(originY, rect.height); 172 | 173 | if (relativeTo === 'content') { 174 | originX += this.x; 175 | originY += this.y; 176 | } else { 177 | const currentRect = this._positioningEl.getBoundingClientRect(); 178 | originX -= currentRect.left; 179 | originY -= currentRect.top; 180 | } 181 | 182 | this._applyChange({ 183 | allowChangeEvent, 184 | originX, 185 | originY, 186 | scaleDiff: scale / this.scale, 187 | }); 188 | } 189 | 190 | /** 191 | * Update the stage with a given scale/x/y. 192 | */ 193 | setTransform(opts: SetTransformOpts = {}) { 194 | const { 195 | scale = this.scale, 196 | allowChangeEvent = false, 197 | } = opts; 198 | 199 | let { 200 | x = this.x, 201 | y = this.y, 202 | } = opts; 203 | 204 | // If we don't have an element to position, just set the value as given. 205 | // We'll check bounds later. 206 | if (!this._positioningEl) { 207 | this._updateTransform(scale, x, y, allowChangeEvent); 208 | return; 209 | } 210 | 211 | // Get current layout 212 | const thisBounds = this.getBoundingClientRect(); 213 | const positioningElBounds = this._positioningEl.getBoundingClientRect(); 214 | 215 | // Not displayed. May be disconnected or display:none. 216 | // Just take the values, and we'll check bounds later. 217 | if (!thisBounds.width || !thisBounds.height) { 218 | this._updateTransform(scale, x, y, allowChangeEvent); 219 | return; 220 | } 221 | 222 | // Create points for _positioningEl. 223 | let topLeft = createPoint(); 224 | topLeft.x = positioningElBounds.left - thisBounds.left; 225 | topLeft.y = positioningElBounds.top - thisBounds.top; 226 | let bottomRight = createPoint(); 227 | bottomRight.x = positioningElBounds.width + topLeft.x; 228 | bottomRight.y = positioningElBounds.height + topLeft.y; 229 | 230 | // Calculate the intended position of _positioningEl. 231 | const matrix = createMatrix() 232 | .translate(x, y) 233 | .scale(scale) 234 | // Undo current transform 235 | .multiply(this._transform.inverse()); 236 | 237 | topLeft = topLeft.matrixTransform(matrix); 238 | bottomRight = bottomRight.matrixTransform(matrix); 239 | 240 | // Ensure _positioningEl can't move beyond out-of-bounds. 241 | // Correct for x 242 | if (topLeft.x > thisBounds.width) { 243 | x += thisBounds.width - topLeft.x; 244 | } else if (bottomRight.x < 0) { 245 | x += -bottomRight.x; 246 | } 247 | 248 | // Correct for y 249 | if (topLeft.y > thisBounds.height) { 250 | y += thisBounds.height - topLeft.y; 251 | } else if (bottomRight.y < 0) { 252 | y += -bottomRight.y; 253 | } 254 | 255 | this._updateTransform(scale, x, y, allowChangeEvent); 256 | } 257 | 258 | /** 259 | * Update transform values without checking bounds. This is only called in setTransform. 260 | */ 261 | private _updateTransform(scale: number, x: number, y: number, allowChangeEvent: boolean) { 262 | // Avoid scaling to zero 263 | if (scale < MIN_SCALE) return; 264 | 265 | // Return if there's no change 266 | if ( 267 | scale === this.scale && 268 | x === this.x && 269 | y === this.y 270 | ) return; 271 | 272 | this._transform.e = x; 273 | this._transform.f = y; 274 | this._transform.d = this._transform.a = scale; 275 | 276 | this.style.setProperty('--x', this.x + 'px'); 277 | this.style.setProperty('--y', this.y + 'px'); 278 | this.style.setProperty('--scale', this.scale + ''); 279 | 280 | if (allowChangeEvent) { 281 | const event = new Event('change', { bubbles: true }); 282 | this.dispatchEvent(event); 283 | } 284 | } 285 | 286 | /** 287 | * Called when the direct children of this element change. 288 | * Until we have have shadow dom support across the board, we 289 | * require a single element to be the child of , and 290 | * that's the element we pan/scale. 291 | */ 292 | private _stageElChange() { 293 | this._positioningEl = undefined; 294 | 295 | if (this.children.length === 0) return; 296 | 297 | this._positioningEl = this.children[0]; 298 | 299 | if (this.children.length > 1) { 300 | console.warn(' must not have more than one child.'); 301 | } 302 | 303 | // Do a bounds check 304 | this.setTransform({ allowChangeEvent: true }); 305 | } 306 | 307 | private _onWheel(event: WheelEvent) { 308 | if (!this._positioningEl) return; 309 | event.preventDefault(); 310 | 311 | const currentRect = this._positioningEl.getBoundingClientRect(); 312 | let { deltaY } = event; 313 | const { ctrlKey, deltaMode } = event; 314 | 315 | if (deltaMode === 1) { // 1 is "lines", 0 is "pixels" 316 | // Firefox uses "lines" for some types of mouse 317 | deltaY *= 15; 318 | } 319 | 320 | // ctrlKey is true when pinch-zooming on a trackpad. 321 | const divisor = ctrlKey ? 100 : 300; 322 | const scaleDiff = 1 - deltaY / divisor; 323 | 324 | this._applyChange({ 325 | scaleDiff, 326 | originX: event.clientX - currentRect.left, 327 | originY: event.clientY - currentRect.top, 328 | allowChangeEvent: true, 329 | }); 330 | } 331 | 332 | private _onPointerMove(previousPointers: Pointer[], currentPointers: Pointer[]) { 333 | if (!this._positioningEl) return; 334 | 335 | // Combine next points with previous points 336 | const currentRect = this._positioningEl.getBoundingClientRect(); 337 | 338 | // For calculating panning movement 339 | const prevMidpoint = getMidpoint(previousPointers[0], previousPointers[1]); 340 | const newMidpoint = getMidpoint(currentPointers[0], currentPointers[1]); 341 | 342 | // Midpoint within the element 343 | const originX = prevMidpoint.clientX - currentRect.left; 344 | const originY = prevMidpoint.clientY - currentRect.top; 345 | 346 | // Calculate the desired change in scale 347 | const prevDistance = getDistance(previousPointers[0], previousPointers[1]); 348 | const newDistance = getDistance(currentPointers[0], currentPointers[1]); 349 | const scaleDiff = prevDistance ? newDistance / prevDistance : 1; 350 | 351 | this._applyChange({ 352 | originX, originY, scaleDiff, 353 | panX: newMidpoint.clientX - prevMidpoint.clientX, 354 | panY: newMidpoint.clientY - prevMidpoint.clientY, 355 | allowChangeEvent: true, 356 | }); 357 | } 358 | 359 | /** Transform the view & fire a change event */ 360 | private _applyChange(opts: ApplyChangeOpts = {}) { 361 | const { 362 | panX = 0, panY = 0, 363 | originX = 0, originY = 0, 364 | scaleDiff = 1, 365 | allowChangeEvent = false, 366 | } = opts; 367 | 368 | const matrix = createMatrix() 369 | // Translate according to panning. 370 | .translate(panX, panY) 371 | // Scale about the origin. 372 | .translate(originX, originY) 373 | // Apply current translate 374 | .translate(this.x, this.y) 375 | .scale(scaleDiff) 376 | .translate(-originX, -originY) 377 | // Apply current scale. 378 | .scale(this.scale); 379 | 380 | // Convert the transform into basic translate & scale. 381 | this.setTransform({ 382 | allowChangeEvent, 383 | scale: matrix.a, 384 | x: matrix.e, 385 | y: matrix.f, 386 | }); 387 | } 388 | } 389 | 390 | customElements.define('pinch-zoom', PinchZoom); 391 | -------------------------------------------------------------------------------- /test/fixtures/typescript/pointer-tracker.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | const enum Button { Left } 18 | 19 | class Pointer { 20 | /** x offset from the top of the document */ 21 | pageX: number; 22 | /** y offset from the top of the document */ 23 | pageY: number; 24 | /** x offset from the top of the viewport */ 25 | clientX: number; 26 | /** y offset from the top of the viewport */ 27 | clientY: number; 28 | /** Unique ID for this pointer */ 29 | id: number = -1; 30 | /** The platform object used to create this Pointer */ 31 | nativePointer: Touch | PointerEvent | MouseEvent; 32 | 33 | constructor (nativePointer: Touch | PointerEvent | MouseEvent) { 34 | this.nativePointer = nativePointer; 35 | this.pageX = nativePointer.pageX; 36 | this.pageY = nativePointer.pageY; 37 | this.clientX = nativePointer.clientX; 38 | this.clientY = nativePointer.clientY; 39 | 40 | if (self.Touch && nativePointer instanceof Touch) { 41 | this.id = nativePointer.identifier; 42 | } else if (isPointerEvent(nativePointer)) { // is PointerEvent 43 | this.id = nativePointer.pointerId; 44 | } 45 | } 46 | 47 | /** 48 | * Returns an expanded set of Pointers for high-resolution inputs. 49 | */ 50 | getCoalesced(): Pointer[] { 51 | if ('getCoalescedEvents' in this.nativePointer) { 52 | return this.nativePointer.getCoalescedEvents().map(p => new Pointer(p)); 53 | } 54 | return [this]; 55 | } 56 | } 57 | 58 | // Export the typing, but keep the class private. 59 | type PointerType = Pointer; 60 | export { PointerType as Pointer }; 61 | 62 | const isPointerEvent = (event: any): event is PointerEvent => 63 | self.PointerEvent && event instanceof PointerEvent; 64 | 65 | const noop = () => {}; 66 | 67 | export type InputEvent = TouchEvent | PointerEvent | MouseEvent; 68 | type StartCallback = (pointer: Pointer, event: InputEvent) => boolean; 69 | type MoveCallback = ( 70 | previousPointers: Pointer[], 71 | changedPointers: Pointer[], 72 | event: InputEvent, 73 | ) => void; 74 | type EndCallback = (pointer: Pointer, event: InputEvent) => void; 75 | 76 | interface PointerTrackerCallbacks { 77 | /** 78 | * Called when a pointer is pressed/touched within the element. 79 | * 80 | * @param pointer The new pointer. 81 | * This pointer isn't included in this.currentPointers or this.startPointers yet. 82 | * @param event The event related to this pointer. 83 | * 84 | * @returns Whether you want to track this pointer as it moves. 85 | */ 86 | start?: StartCallback; 87 | /** 88 | * Called when pointers have moved. 89 | * 90 | * @param previousPointers The state of the pointers before this event. 91 | * This contains the same number of pointers, in the same order, as 92 | * this.currentPointers and this.startPointers. 93 | * @param changedPointers The pointers that have changed since the last move callback. 94 | * @param event The event related to the pointer changes. 95 | */ 96 | move?: MoveCallback; 97 | /** 98 | * Called when a pointer is released. 99 | * 100 | * @param pointer The final state of the pointer that ended. This 101 | * pointer is now absent from this.currentPointers and 102 | * this.startPointers. 103 | * @param event The event related to this pointer. 104 | */ 105 | end?: EndCallback; 106 | } 107 | 108 | /** 109 | * Track pointers across a particular element 110 | */ 111 | export default class PointerTracker { 112 | /** 113 | * State of the tracked pointers when they were pressed/touched. 114 | */ 115 | readonly startPointers: Pointer[] = []; 116 | /** 117 | * Latest state of the tracked pointers. Contains the same number 118 | * of pointers, and in the same order as this.startPointers. 119 | */ 120 | readonly currentPointers: Pointer[] = []; 121 | 122 | private _startCallback: StartCallback; 123 | private _moveCallback: MoveCallback; 124 | private _endCallback: EndCallback; 125 | 126 | /** 127 | * Track pointers across a particular element 128 | * 129 | * @param element Element to monitor. 130 | * @param callbacks 131 | */ 132 | constructor (private _element: HTMLElement, callbacks: PointerTrackerCallbacks) { 133 | const { 134 | start = () => true, 135 | move = noop, 136 | end = noop, 137 | } = callbacks; 138 | 139 | this._startCallback = start; 140 | this._moveCallback = move; 141 | this._endCallback = end; 142 | 143 | // Bind methods 144 | this._pointerStart = this._pointerStart.bind(this); 145 | this._touchStart = this._touchStart.bind(this); 146 | this._move = this._move.bind(this); 147 | this._triggerPointerEnd = this._triggerPointerEnd.bind(this); 148 | this._pointerEnd = this._pointerEnd.bind(this); 149 | this._touchEnd = this._touchEnd.bind(this); 150 | 151 | // Add listeners 152 | if (self.PointerEvent) { 153 | this._element.addEventListener('pointerdown', this._pointerStart); 154 | } else { 155 | this._element.addEventListener('mousedown', this._pointerStart); 156 | this._element.addEventListener('touchstart', this._touchStart); 157 | this._element.addEventListener('touchmove', this._move); 158 | this._element.addEventListener('touchend', this._touchEnd); 159 | } 160 | } 161 | 162 | /** 163 | * Call the start callback for this pointer, and track it if the user wants. 164 | * 165 | * @param pointer Pointer 166 | * @param event Related event 167 | * @returns Whether the pointer is being tracked. 168 | */ 169 | private _triggerPointerStart(pointer: Pointer, event: InputEvent): boolean { 170 | if (!this._startCallback(pointer, event)) return false; 171 | this.currentPointers.push(pointer); 172 | this.startPointers.push(pointer); 173 | return true; 174 | } 175 | 176 | /** 177 | * Listener for mouse/pointer starts. Bound to the class in the constructor. 178 | * 179 | * @param event This will only be a MouseEvent if the browser doesn't support 180 | * pointer events. 181 | */ 182 | private _pointerStart(event: PointerEvent | MouseEvent) { 183 | if (event.button !== Button.Left) return; 184 | if (!this._triggerPointerStart(new Pointer(event), event)) return; 185 | 186 | // Add listeners for additional events. 187 | // The listeners may already exist, but no harm in adding them again. 188 | if (isPointerEvent(event)) { 189 | this._element.setPointerCapture(event.pointerId); 190 | this._element.addEventListener('pointermove', this._move); 191 | this._element.addEventListener('pointerup', this._pointerEnd); 192 | } else { // MouseEvent 193 | window.addEventListener('mousemove', this._move); 194 | window.addEventListener('mouseup', this._pointerEnd); 195 | } 196 | } 197 | 198 | /** 199 | * Listener for touchstart. Bound to the class in the constructor. 200 | * Only used if the browser doesn't support pointer events. 201 | */ 202 | private _touchStart(event: TouchEvent) { 203 | for (const touch of Array.from(event.changedTouches)) { 204 | this._triggerPointerStart(new Pointer(touch), event); 205 | } 206 | } 207 | 208 | /** 209 | * Listener for pointer/mouse/touch move events. 210 | * Bound to the class in the constructor. 211 | */ 212 | private _move(event: PointerEvent | MouseEvent | TouchEvent) { 213 | const previousPointers = this.currentPointers.slice(); 214 | const changedPointers = ('changedTouches' in event) ? // Shortcut for 'is touch event'. 215 | Array.from(event.changedTouches).map(t => new Pointer(t)) : 216 | [new Pointer(event)]; 217 | const trackedChangedPointers = []; 218 | 219 | for (const pointer of changedPointers) { 220 | const index = this.currentPointers.findIndex(p => p.id === pointer.id); 221 | if (index === -1) continue; // Not a pointer we're tracking 222 | trackedChangedPointers.push(pointer); 223 | this.currentPointers[index] = pointer; 224 | } 225 | 226 | if (trackedChangedPointers.length === 0) return; 227 | 228 | this._moveCallback(previousPointers, trackedChangedPointers, event); 229 | } 230 | 231 | /** 232 | * Call the end callback for this pointer. 233 | * 234 | * @param pointer Pointer 235 | * @param event Related event 236 | */ 237 | private _triggerPointerEnd(pointer: Pointer, event: InputEvent): boolean { 238 | const index = this.currentPointers.findIndex(p => p.id === pointer.id); 239 | // Not a pointer we're interested in? 240 | if (index === -1) return false; 241 | 242 | this.currentPointers.splice(index, 1); 243 | this.startPointers.splice(index, 1); 244 | 245 | this._endCallback(pointer, event); 246 | return true; 247 | } 248 | 249 | /** 250 | * Listener for mouse/pointer ends. Bound to the class in the constructor. 251 | * @param event This will only be a MouseEvent if the browser doesn't support 252 | * pointer events. 253 | */ 254 | private _pointerEnd(event: PointerEvent | MouseEvent) { 255 | if (!this._triggerPointerEnd(new Pointer(event), event)) return; 256 | 257 | if (isPointerEvent(event)) { 258 | if (this.currentPointers.length) return; 259 | this._element.removeEventListener('pointermove', this._move); 260 | this._element.removeEventListener('pointerup', this._pointerEnd); 261 | } else { // MouseEvent 262 | window.removeEventListener('mousemove', this._move); 263 | window.removeEventListener('mouseup', this._pointerEnd); 264 | } 265 | } 266 | 267 | /** 268 | * Listener for touchend. Bound to the class in the constructor. 269 | * Only used if the browser doesn't support pointer events. 270 | */ 271 | private _touchEnd(event: TouchEvent) { 272 | for (const touch of Array.from(event.changedTouches)) { 273 | this._triggerPointerEnd(new Pointer(touch), event); 274 | } 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /test/fixtures/typescript/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "sourceMap": false, 4 | "target": "ES2018" 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /test/fixtures/typescript/util.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | /** Compare two objects, returning a boolean indicating if 18 | * they have the same properties and strictly equal values. 19 | */ 20 | export function shallowEqual(one: any, two: any) { 21 | for (const i in one) if (one[i] !== two[i]) return false; 22 | for (const i in two) if (!(i in one)) return false; 23 | return true; 24 | } 25 | 26 | /** Replace the contents of a canvas with the given data */ 27 | export function drawDataToCanvas(canvas: HTMLCanvasElement, data: ImageData) { 28 | const ctx = canvas.getContext('2d'); 29 | if (!ctx) throw Error('Canvas not initialized'); 30 | ctx.clearRect(0, 0, canvas.width, canvas.height); 31 | ctx.putImageData(data, 0, 0); 32 | } 33 | 34 | /** 35 | * Encode some image data in a given format using the browser's encoders 36 | * 37 | * @param {ImageData} data 38 | * @param {string} type A mime type, eg image/jpeg. 39 | * @param {number} [quality] Between 0-1. 40 | */ 41 | export async function canvasEncode(data: ImageData, type: string, quality?: number): Promise { 42 | const canvas = document.createElement('canvas'); 43 | canvas.width = data.width; 44 | canvas.height = data.height; 45 | const ctx = canvas.getContext('2d'); 46 | if (!ctx) throw Error('Canvas not initialized'); 47 | ctx.putImageData(data, 0, 0); 48 | 49 | let blob: Blob | null; 50 | 51 | if ('toBlob' in canvas) { 52 | blob = await new Promise(r => canvas.toBlob(r, type, quality)); 53 | } else { 54 | // Welcome to Edge. 55 | // TypeScript thinks `canvas` is 'never', so it needs casting. 56 | const dataUrl = (canvas as HTMLCanvasElement).toDataURL(type, quality); 57 | const result = /data:([^;]+);base64,(.*)$/.exec(dataUrl); 58 | 59 | if (!result) throw Error('Data URL reading failed'); 60 | 61 | const outputType = result[1]; 62 | const binaryStr = atob(result[2]); 63 | const data = new Uint8Array(binaryStr.length); 64 | 65 | for (let i = 0; i < data.length; i += 1) { 66 | data[i] = binaryStr.charCodeAt(i); 67 | } 68 | 69 | blob = new Blob([data], { type: outputType }); 70 | } 71 | 72 | if (!blob) throw Error('Encoding failed'); 73 | return blob; 74 | } 75 | 76 | async function decodeImage(url: string): Promise { 77 | const img = new Image(); 78 | img.decoding = 'async'; 79 | img.src = url; 80 | const loaded = new Promise((resolve, reject) => { 81 | img.onload = () => resolve(); 82 | img.onerror = () => reject(Error('Image loading error')); 83 | }); 84 | 85 | if (img.decode) { 86 | // Nice off-thread way supported in Safari/Chrome. 87 | // Safari throws on decode if the source is SVG. 88 | // https://bugs.webkit.org/show_bug.cgi?id=188347 89 | await img.decode().catch(() => null); 90 | } 91 | 92 | // Always await loaded, as we may have bailed due to the Safari bug above. 93 | await loaded; 94 | return img; 95 | } 96 | 97 | /** 98 | * Attempts to load the given URL as an image. 99 | */ 100 | export function canDecodeImage(url: string): Promise { 101 | return decodeImage(url).then(() => true, () => false); 102 | } 103 | 104 | export function blobToArrayBuffer(blob: Blob): Promise { 105 | return new Response(blob).arrayBuffer(); 106 | } 107 | 108 | export function blobToText(blob: Blob): Promise { 109 | return new Response(blob).text(); 110 | } 111 | 112 | const magicNumberToMimeType = new Map([ 113 | [/^%PDF-/, 'application/pdf'], 114 | [/^GIF87a/, 'image/gif'], 115 | [/^GIF89a/, 'image/gif'], 116 | [/^\x89PNG\x0D\x0A\x1A\x0A/, 'image/png'], 117 | [/^\xFF\xD8\xFF/, 'image/jpeg'], 118 | [/^BM/, 'image/bmp'], 119 | [/^I I/, 'image/tiff'], 120 | [/^II*/, 'image/tiff'], 121 | [/^MM\x00*/, 'image/tiff'], 122 | [/^RIFF....WEBPVP8[LX ]/, 'image/webp'], 123 | ]); 124 | 125 | export async function sniffMimeType(blob: Blob): Promise { 126 | const firstChunk = await blobToArrayBuffer(blob.slice(0, 16)); 127 | const firstChunkString = 128 | Array.from(new Uint8Array(firstChunk)) 129 | .map(v => String.fromCodePoint(v)) 130 | .join(''); 131 | for (const [detector, mimeType] of magicNumberToMimeType) { 132 | if (detector.test(firstChunkString)) { 133 | return mimeType; 134 | } 135 | } 136 | return ''; 137 | } 138 | 139 | export async function blobToImg(blob: Blob): Promise { 140 | const url = URL.createObjectURL(blob); 141 | 142 | try { 143 | return await decodeImage(url); 144 | } finally { 145 | URL.revokeObjectURL(url); 146 | } 147 | } 148 | 149 | interface DrawableToImageDataOptions { 150 | width?: number; 151 | height?: number; 152 | sx?: number; 153 | sy?: number; 154 | sw?: number; 155 | sh?: number; 156 | } 157 | 158 | export function drawableToImageData( 159 | drawable: ImageBitmap | HTMLImageElement, 160 | opts: DrawableToImageDataOptions = {}, 161 | ): ImageData { 162 | const { 163 | width = drawable.width, 164 | height = drawable.height, 165 | sx = 0, 166 | sy = 0, 167 | sw = drawable.width, 168 | sh = drawable.height, 169 | } = opts; 170 | 171 | // Make canvas same size as image 172 | const canvas = document.createElement('canvas'); 173 | canvas.width = width; 174 | canvas.height = height; 175 | // Draw image onto canvas 176 | const ctx = canvas.getContext('2d'); 177 | if (!ctx) throw new Error('Could not create canvas context'); 178 | ctx.drawImage(drawable, sx, sy, sw, sh, 0, 0, width, height); 179 | return ctx.getImageData(0, 0, width, height); 180 | } 181 | 182 | export async function nativeDecode(blob: Blob): Promise { 183 | // Prefer createImageBitmap as it's the off-thread option for Firefox. 184 | const drawable = 'createImageBitmap' in self ? 185 | await createImageBitmap(blob) : await blobToImg(blob); 186 | 187 | return drawableToImageData(drawable); 188 | } 189 | 190 | export type NativeResizeMethod = 'pixelated' | 'low' | 'medium' | 'high'; 191 | 192 | export function nativeResize( 193 | data: ImageData, 194 | sx: number, sy: number, sw: number, sh: number, 195 | dw: number, dh: number, 196 | method: NativeResizeMethod, 197 | ): ImageData { 198 | const canvasSource = document.createElement('canvas'); 199 | canvasSource.width = data.width; 200 | canvasSource.height = data.height; 201 | drawDataToCanvas(canvasSource, data); 202 | 203 | const canvasDest = document.createElement('canvas'); 204 | canvasDest.width = dw; 205 | canvasDest.height = dh; 206 | const ctx = canvasDest.getContext('2d'); 207 | if (!ctx) throw new Error('Could not create canvas context'); 208 | 209 | if (method === 'pixelated') { 210 | ctx.imageSmoothingEnabled = false; 211 | } else { 212 | ctx.imageSmoothingQuality = method; 213 | } 214 | 215 | ctx.drawImage(canvasSource, sx, sy, sw, sh, 0, 0, dw, dh); 216 | return ctx.getImageData(0, 0, dw, dh); 217 | } 218 | 219 | /** 220 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange. 221 | * @param defaultVal Value to return if 'field' doesn't exist. 222 | */ 223 | export function inputFieldValueAsNumber(field: any, defaultVal: number = 0): number { 224 | if (!field) return defaultVal; 225 | return Number(inputFieldValue(field)); 226 | } 227 | 228 | /** 229 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange. 230 | * @param defaultVal Value to return if 'field' doesn't exist. 231 | */ 232 | export function inputFieldCheckedAsNumber(field: any, defaultVal: number = 0): number { 233 | if (!field) return defaultVal; 234 | return Number(inputFieldChecked(field)); 235 | } 236 | 237 | /** 238 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange. 239 | * @param defaultVal Value to return if 'field' doesn't exist. 240 | */ 241 | export function inputFieldChecked(field: any, defaultVal: boolean = false): boolean { 242 | if (!field) return defaultVal; 243 | return (field as HTMLInputElement).checked; 244 | } 245 | 246 | /** 247 | * @param field An HTMLInputElement, but the casting is done here to tidy up onChange. 248 | * @param defaultVal Value to return if 'field' doesn't exist. 249 | */ 250 | export function inputFieldValue(field: any, defaultVal: string = ''): string { 251 | if (!field) return defaultVal; 252 | return (field as HTMLInputElement).value; 253 | } 254 | 255 | /** 256 | * Creates a promise that resolves when the user types the konami code. 257 | */ 258 | export function konami(): Promise { 259 | return new Promise((resolve) => { 260 | // Keycodes for: ↑ ↑ ↓ ↓ ← → ← → B A 261 | const expectedPattern = '38384040373937396665'; 262 | let rollingPattern = ''; 263 | 264 | const listener = (event: KeyboardEvent) => { 265 | rollingPattern += event.keyCode; 266 | rollingPattern = rollingPattern.slice(-expectedPattern.length); 267 | if (rollingPattern === expectedPattern) { 268 | window.removeEventListener('keydown', listener); 269 | resolve(); 270 | } 271 | }; 272 | 273 | window.addEventListener('keydown', listener); 274 | }); 275 | } 276 | 277 | interface TransitionOptions { 278 | from?: number; 279 | to?: number; 280 | duration?: number; 281 | easing?: string; 282 | } 283 | 284 | export async function transitionHeight(el: HTMLElement, opts: TransitionOptions): Promise { 285 | const { 286 | from = el.getBoundingClientRect().height, 287 | to = el.getBoundingClientRect().height, 288 | duration = 1000, 289 | easing = 'ease-in-out', 290 | } = opts; 291 | 292 | if (from === to || duration === 0) { 293 | el.style.height = to + 'px'; 294 | return; 295 | } 296 | 297 | el.style.height = from + 'px'; 298 | // Force a style calc so the browser picks up the start value. 299 | getComputedStyle(el).transform; 300 | el.style.transition = `height ${duration}ms ${easing}`; 301 | el.style.height = to + 'px'; 302 | 303 | return new Promise((resolve) => { 304 | const listener = (event: Event) => { 305 | if (event.target !== el) return; 306 | el.style.transition = ''; 307 | el.removeEventListener('transitionend', listener); 308 | el.removeEventListener('transitioncancel', listener); 309 | resolve(); 310 | }; 311 | 312 | el.addEventListener('transitionend', listener); 313 | el.addEventListener('transitioncancel', listener); 314 | }); 315 | } 316 | 317 | /** 318 | * Simple event listener that prevents the default. 319 | */ 320 | export function preventDefault(event: Event) { 321 | event.preventDefault(); 322 | } 323 | -------------------------------------------------------------------------------- /test/index.test.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Copyright 2020 Google LLC 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); you may not 5 | * use this file except in compliance with the License. You may obtain a copy of 6 | * the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, WITHOUT 12 | * WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the 13 | * License for the specific language governing permissions and limitations under 14 | * the License. 15 | */ 16 | 17 | import { resolve } from 'path'; 18 | import OptimizePlugin from '..'; 19 | import { webpacks } from './_webpacks'; 20 | import { runWebpack, printSizes } from './_util'; 21 | 22 | jest.setTimeout(30000); 23 | 24 | // easier for debugging 25 | const concurrency = false; 26 | 27 | describe('optimize-plugin', () => { 28 | const $console = { 29 | log: console.log, 30 | warn: console.warn, 31 | info: console.info 32 | }; 33 | 34 | beforeAll(() => { 35 | console.warn = () => 0; 36 | console.log = () => 0; 37 | console.info = () => 0; 38 | }); 39 | 40 | afterAll(() => { 41 | console.warn = $console.warn; 42 | console.log = $console.log; 43 | console.info = $console.info; 44 | }); 45 | 46 | describe.each(webpacks)('webpack %i', (_, webpack, tsLoader) => { 47 | test('exports a class', () => { 48 | expect(OptimizePlugin).toBeInstanceOf(Function); 49 | expect(OptimizePlugin.prototype).toHaveProperty('apply', expect.any(Function)); 50 | }); 51 | 52 | test('it works', async () => { 53 | const stats = await runWebpack(webpack, 'basic', { 54 | module: { 55 | rules: [ 56 | { 57 | test: /\.jsx?$/, 58 | loader: resolve(__dirname, '_sucrase-loader.js') 59 | } 60 | ] 61 | }, 62 | plugins: [ 63 | new OptimizePlugin({ concurrency }, webpack) 64 | ] 65 | }, $console); 66 | 67 | const assetNames = Object.keys(stats.assets); 68 | expect(assetNames).toHaveLength(3); 69 | 70 | expect(assetNames).toContain('main.js'); 71 | expect(assetNames).toContain('main.legacy.js'); 72 | expect(assetNames).toContain('polyfills.legacy.js'); 73 | 74 | const main = stats.assets['main.js']; 75 | expect(main).toMatch(/hello world/g); 76 | 77 | const legacy = stats.assets['main.legacy.js']; 78 | expect(legacy).toMatch(/hello world/g); 79 | 80 | const polyfills = stats.assets['polyfills.legacy.js']; 81 | expect(polyfills).toMatch(/Object\.defineProperty/g); 82 | expect(polyfills).not.toMatch(/require\(/g); 83 | 84 | await printSizes(stats.assets, '"it works"', $console); 85 | }); 86 | 87 | test('code splitting', async () => { 88 | const stats = await runWebpack(webpack, 'code-splitting', { 89 | output: { 90 | chunkFilename: '[name].js' 91 | }, 92 | plugins: [ 93 | new OptimizePlugin({ concurrency }, webpack) 94 | ] 95 | }, $console); 96 | 97 | const assetNames = Object.keys(stats.assets); 98 | expect(assetNames).toHaveLength(9); 99 | 100 | expect(assetNames).toContain('main.js'); 101 | expect(assetNames).toContain('main.legacy.js'); 102 | expect(assetNames).toContain('home.js'); 103 | expect(assetNames).toContain('home.legacy.js'); 104 | expect(assetNames).toContain('about.js'); 105 | expect(assetNames).toContain('about.legacy.js'); 106 | expect(assetNames).toContain('profile.js'); 107 | expect(assetNames).toContain('profile.legacy.js'); 108 | expect(assetNames).toContain('polyfills.legacy.js'); 109 | 110 | const main = stats.assets['main.js']; 111 | expect(main).toMatch(/hello world/g); 112 | 113 | const legacy = stats.assets['main.legacy.js']; 114 | expect(legacy).toMatch(/hello world/g); 115 | 116 | const polyfills = stats.assets['polyfills.legacy.js']; 117 | expect(polyfills).toMatch(/Object\.defineProperty/g); 118 | expect(polyfills).not.toMatch(/require\(/g); 119 | 120 | await printSizes(stats.assets, 'code splitting', $console); 121 | }); 122 | 123 | describe('TypeScript Support', () => { 124 | test('using ts-loader', async () => { 125 | const stats = await runWebpack(webpack, 'typescript', { 126 | resolve: { 127 | extensions: ['.ts', '.js'] 128 | }, 129 | module: { 130 | rules: [ 131 | { 132 | test: /\.tsx?$/, 133 | loader: tsLoader, 134 | options: { 135 | transpileOnly: true 136 | } 137 | } 138 | ] 139 | }, 140 | plugins: [ 141 | new OptimizePlugin({ concurrency }, webpack) 142 | ] 143 | }, $console); 144 | 145 | const assetNames = Object.keys(stats.assets); 146 | expect(assetNames).toHaveLength(3); 147 | 148 | expect(assetNames).toContain('main.js'); 149 | expect(assetNames).toContain('main.legacy.js'); 150 | expect(assetNames).toContain('polyfills.legacy.js'); 151 | 152 | const main = stats.assets['main.js']; 153 | expect(main).toMatch(/pinch-zoom/g); 154 | 155 | const legacy = stats.assets['main.legacy.js']; 156 | expect(legacy).toMatch(/pinch-zoom/g); 157 | 158 | const polyfills = stats.assets['polyfills.legacy.js']; 159 | expect(polyfills).toMatch(/Object\.defineProperty/g); 160 | expect(polyfills).not.toMatch(/require\(/g); 161 | 162 | await printSizes(stats.assets, 'typescript support', $console); 163 | }); 164 | 165 | test('using Sucrase', async () => { 166 | const stats = await runWebpack(webpack, 'typescript', { 167 | resolve: { 168 | extensions: ['.ts', '.js'] 169 | }, 170 | module: { 171 | rules: [ 172 | { 173 | test: /\.tsx?$/, 174 | loader: resolve(__dirname, '_sucrase-loader.js') 175 | } 176 | ] 177 | }, 178 | plugins: [ 179 | new OptimizePlugin({ concurrency }, webpack) 180 | ] 181 | }, $console); 182 | 183 | const assetNames = Object.keys(stats.assets); 184 | expect(assetNames).toHaveLength(3); 185 | 186 | expect(assetNames).toContain('main.js'); 187 | expect(assetNames).toContain('main.legacy.js'); 188 | expect(assetNames).toContain('polyfills.legacy.js'); 189 | 190 | const main = stats.assets['main.js']; 191 | expect(main).toMatch(/pinch-zoom/g); 192 | 193 | const legacy = stats.assets['main.legacy.js']; 194 | expect(legacy).toMatch(/pinch-zoom/g); 195 | 196 | const polyfills = stats.assets['polyfills.legacy.js']; 197 | expect(polyfills).toMatch(/Object\.defineProperty/g); 198 | expect(polyfills).not.toMatch(/require\(/g); 199 | 200 | await printSizes(stats.assets, 'sucrase', $console); 201 | }); 202 | }); 203 | }); 204 | }); 205 | -------------------------------------------------------------------------------- /test/webpacks/4/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-4", 3 | "version": "1.0.0", 4 | "dependencies": { 5 | "ts-loader": "^8.3.0", 6 | "webpack": "^4.46.0" 7 | } 8 | } 9 | --------------------------------------------------------------------------------