Webpack: Common chunks for code shared between Webworker and Web code? - javascript

I have lots of code shared between web and web worker parts of my browser app.
How can I tell webpack to split my code up into common chunks so that the result is garanteed to work 100%?
The webworker code breaks (fails at runtime) after I tell webpack to generate the common chunks (which it does). Even after I fix the trivial "window not defined" error the worker just does nothing.
I believe this has to do with the webpack "target" option, which per default is set to "web". But I need "web" target because I don't have purely webworker code.
I also cannot do multiple webpack configs because I cannot do the common chunks thing with multiple configs...
What should I do?
If anybody is interested: I am trying build a minimal sized build for my app which includes the monaco editor (which provides the workers):
https://github.com/Microsoft/monaco-editor/blob/master/docs/integrate-esm.md
You can see here (at the bottom of the page) that the entry points consist of 1 main entry file + the workers.
Currently at least 6 MB is wasted because of duplicate code I am using and currently can not be split up because of this problem. That is a lot of wasted traffic.
Any ideas? :)
my webpack 4.1.1 config is basically:
module.exports = (env, options) => {
const mode = options.mode;
const isProduction = mode === 'production';
const outDir = isProduction ? 'build/release' : 'build/debug';
return {
entry: {
"app": "./src/main.tsx",
"editor.worker": 'monaco-editor/esm/vs/editor/editor.worker.js',
"ts.worker": 'monaco-editor/esm/vs/language/typescript/ts.worker.js'
},
output: {
filename: "[name].bundle.js",
path: `${__dirname}/${outDir}`,
libraryTarget: 'umd',
globalObject: 'this',
library: 'app',
umdNamedDefine: true
},
node: {
fs: 'empty'
},
devtool: isProduction ? undefined : "source-map",
resolve: {
extensions: [".ts", ".tsx", ".js", ".json"],
alias: {
"#components": path.resolve(__dirname, "src/components"),
"#lib": path.resolve(__dirname, "src/lib"),
"#common": path.resolve(__dirname, "src/common"),
"#redux": path.resolve(__dirname, "src/redux"),
"#services": path.resolve(__dirname, "src/services"),
"#translations": path.resolve(__dirname, "src/translations"),
"#serverApi": path.resolve(__dirname, "src/server-api")
}
},
optimization: isProduction ? undefined : {
splitChunks: {
minSize: 30000,
minChunks: 1,
name: true,
maxAsyncRequests: 100,
maxInitialRequests: 100,
cacheGroups: {
default: {
chunks: "all",
priority: -100,
test: (module) => {
const req = module.userRequest;
if (!req) return false;
return (!/node_modules[\\/]/.test(req));
},
},
vendor: {
chunks: "all",
test: (module) => {
const req = module.userRequest;
if (!req) return false;
if (!/[\\/]node_modules[\\/]/.test(req)) return false;
return true;
},
priority: 100,
}
}
},
},
module: {
rules: [...(isProduction ? [] : [
{
enforce: "pre", test: /\.js$/, loader: "source-map-loader",
exclude: [
/node_modules[\\/]monaco-editor/
]
}
]),
{
test: require.resolve('jquery.hotkeys'),
use: 'imports-loader?jQuery=jquery'
},
{
test: /\.tsx?$/,
loader: "awesome-typescript-loader",
options: {
configFileName: 'src/tsconfig.json',
getCustomTransformers: () => {
return {
before: [p => keysTransformer(p)]
};
}
}
},
{
test: /\.(css|sass|scss)$/,
use: extractSass.extract({
use: [
{
loader: 'css-loader',
options: {
minimize: isProduction
}
},
{
loader: "postcss-loader",
options: {
plugins: () => [autoprefixer({
browsers: [
'last 3 version',
'ie >= 10'
]
})]
}
},
{ loader: "sass-loader" }
],
fallback: "style-loader"
})
},
{
test: /node_modules[\/\\]font-awesome/,
loader: 'file-loader',
options: {
emitFile: false
}
},
{
test: { not: [{ test: /node_modules[\/\\]font-awesome/ }] },
rules: [
{
test: { or: [/icomoon\.svg$/, /fonts[\/\\]seti\.svg$/] },
rules: [
{ loader: 'file-loader?mimetype=image/svg+xml' },
]
}, {
test: { not: [/icomoon\.svg$/, /fonts[\/\\]seti\.svg$/] },
rules: [
{
test: /\.svg(\?v=\d+\.\d+\.\d+)?$/,
use: {
loader: 'svg-url-loader',
options: {}
}
},
]
},
{
test: /\.(png|jpg|gif)$/,
loader: 'url-loader'
},
{ test: /\.woff(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?mimetype=application/font-woff" },
{ test: /\.woff2(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?mimetype=application/font-woff" },
{ test: /\.ttf(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader?mimetype=application/octet-stream" },
{ test: /\.eot(\?v=\d+\.\d+\.\d+)?$/, loader: "url-loader" },
]
},
]
},
plugins: [
new HardSourceWebpackPlugin({
cacheDirectory: '../node_modules/.cache/hard-source/[confighash]', configHash: function (webpackConfig) {
return require('node-object-hash')({ sort: false }).hash(Object.assign({}, webpackConfig, { devServer: false }));
},
environmentHash: {
root: process.cwd(),
directories: [],
files: ['../package-lock.json'],
}
}),
new webpack.ProvidePlugin({
"window.$": "jquery"
}),
new CleanWebpackPlugin(outDir),
extractSass,
new HtmlWebpackPlugin({
title: 'my title',
filename: 'index.html',
minify: isProduction ? {
collapseWhitespace: true,
collapseInlineTagWhitespace: true,
removeComments: true,
removeRedundantAttributes: true
} : false,
template: 'index_template.html',
excludeChunks: ['ts.worker', "editor.worker"]
}),
new webpack.IgnorePlugin(/^((fs)|(path)|(os)|(crypto)|(source-map-support))$/, /vs[\\\/]language[\\\/]typescript[\\\/]lib/)
].concat(isProduction ? [new webpack.optimize.LimitChunkCountPlugin({
maxChunks: 1
})] : [])
}
};

EDIT: Alright I wrote a webpack plugin based on everyone's knowledge just put together.
https://www.npmjs.com/package/worker-injector-generator-plugin
You can ignore the content below, and use the plugin or if you want to understand how the plugin came to be and do it by hand yourself (so you don't have to depend on my code) you can keep reading.
=====================================================
Alright after so much researching I figured out this solution, you need to create an injection file, for a simple case you need the https://github.com/webpack-contrib/copy-webpack-plugin as it works pretty well... so let's say your setup is:
entry: {
"worker": ["./src/worker.ts"],
"app": ["./src/index.tsx"],
},
And you have setup your common plugins already let's say this example.
optimization: {
splitChunks: {
cacheGroups: {
commons: {
name: 'commons',
chunks: 'initial',
minChunks: 2
},
}
}
},
You need to now create an injection "Vanilla JS" which might look like this:
var base = location.protocol + "//" + location.host;
window = self;
self.importScripts(base + "/resources/commons.js", base + "/resources/worker.js");
Then you can add that alongside your worker, say in src/worker-injector.js
And using the copy plugin
new CopyPlugin([
{
from: "./src/worker-injector.js",
to: path.resolve(__dirname, 'dist/[name].js'),
},
]),
Make sure your output is set to umd.
output: {
filename: '[name].js',
path: path.resolve(__dirname, 'dist'),
libraryTarget: "umd",
globalObject: "this",
}
This is nothing but a hack, but allows you to use everything as it is without having to do something as overblown.
If you need hashing (so that copy plugin doesn't work) functionality you would have to generate this file (rather than copying it), refer to this.
How to inject Webpack build hash to application code
For that you would have to create your own plugin which would generate the vanilla js file and consider the hash within itself, you would pass the urls that you want to load together, and it would attach the hash to them, this is more tricky but if you need hashes it should be straightforward to implement with your custom plugin.
Sadly so far there doesn't seem to be other way.
I could probably write the plugin myself that would do the workaround and create the injectors, but I do think this is more of a hack and shouldn't be the accepted solution.
I might later go around and write the injector plugin, it could be something as:
something like new WorkerInjectorGeneratorPlugin({name: "worker.[hash].injector.js", importScripts: ["urlToLoad.[hash].js", secondURLToLoad.[hash].js"])
refer to this issues for reference, and why it should be fixed within webpack and something as a WorkerInjectorGeneratorPlugin would be pretty much a hack plugin.
https://github.com/webpack/webpack/issues/6472

This is really bad answer, but i've managed to share chunks between workers and main thread.
The clue is that
globalObject has to be defined as above to (self || this):
output: {
globalObject: "(self || this)"
}
Webpack loads chunks with document.createElement('script') and document.head.appendChild() sequence, which is not available in worker context, but we have self.importScript. So it's just a matter of "polyfiling" it.
Here is working "polyfill" (straight from the hell):
console.log("#faking document.createElement()");
(self as any).document = {
createElement(elementName: string): any {
console.log("#fake document.createElement", elementName);
return {};
},
head: {
appendChild(element: any) {
console.log("#fake document.head.appendChild", element);
try {
console.log("loading", element.src);
importScripts(element.src);
element.onload({
target: element,
type: 'load'
})
} catch(error) {
element.onerror({
target: element,
type: 'error'
})
}
}
}
};
Ensure, that your real code is resolved after polyfill is installed, by using dynamic import, which will. Assuming, that normal "worker main" is in "./RealWorkerMain", that would be "main worker script":
// so, typescript recognizes this as module
export let dummy = 2;
// insert "polyfill from hell" from here
import("./RealWorkerMain").then(({ init }) => {
init();
});
You may need to configure dynamic import in webpack, as documented here is not easy too, this answer was very helpful.

You're looking for universal library target, aka umd.
This exposes your library under all the module definitions, allowing
it to work with CommonJS, AMD and as global variable.
To make your Webpack bundle compile to umd you should configure output property like this:
output: {
filename: '[name].bundle.js',
libraryTarget: 'umd',
library: 'yourName',
umdNamedDefine: true,
},
There is an issue with Webpack 4, but if you still want to use it, you can workaround the issue by adding globalObject: 'this' to the configuration:
output: {
filename: '[name].bundle.js',
libraryTarget: 'umd',
library: 'yourName',
umdNamedDefine: true,
globalObject: 'this'
},

Native Worker support is introduced in webpack 5. With this feature, you can share chunks between app code and webwokers with simple splitChunk options like
{
optimization: {
splitChunks: {
chunks: 'all',
minChunks: 2,
},
},
}
When combining new URL for assets with new Worker/new SharedWorker/navigator.serviceWorker.register webpack will automatically create a new entrypoint for a web worker.
new Worker(new URL("./worker.js", import.meta.url))
The syntax was chosen to allow running code without bundler too. This syntax is also available in native ECMAScript modules in the browser.
https://webpack.js.org/blog/2020-10-10-webpack-5-release/#native-worker-support

Related

Webpack Tree-Shaking Dynamic Imports seems not to be working

History:
I recently discovered an odd behaviour with using Webpack and dynamic imports. First I thought it might be the 3rd-party library 'Loadable Components' I used, so I opened a bug issue (https://github.com/gregberge/loadable-components/issues/517) on their end. The author replied telling me that the behaviour is coming from Webpack and the dynamic imports themselves.
I can stand the fact that it does not tree-shake the dynamic import, for me it is more important to understand why that is the case.
Demo repository to demonstrate the behaviour can be found here: https://github.com/dazlious/treeshaking-dynamic-imports
Short description of the problem: From my perspective, an imported named export should not force all the exported code to be bundled within it.
In the demo case we have a component (./lib/index.jsx) that has two sub components called module1 (./lib/module1/module1.jsx) and module2 (./lib/module1/module2.jsx). Module1 exports a constant called FOO_BAR that is then imported by Module2 as a named import.
When looking at the build output, you'll find Module2 containing Module1 in whole and not only the string that is specifically imported.
Is anyone with deep knowledge of Webpack and/or dynamic imports around here? Would be happy to learn more about the behaviour.
I edited the webpack.config to be:
const path = require('path');
const TerserPlugin = require('terser-webpack-plugin');
const { BundleAnalyzerPlugin } = require('webpack-bundle-analyzer');
const baseDir = path.resolve(__dirname);
const config = {
mode: process.env.NODE_ENV,
stats: 'minimal',
resolve: {
extensions: ['.js', '.jsx'],
symlinks: false,
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'static',
openAnalyzer: false,
reportFilename: 'analyze.html',
}),
],
target: 'web',
devtool: 'hidden-source-map',
entry: {
bundle: [path.resolve(baseDir, 'lib')],
},
optimization: {
minimize: true,
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
mangleWasmImports: true,
splitChunks: {
cacheGroups: {
default: false,
vendors: false,
vendor: {
name: 'vendor',
chunks: 'all',
test: /node_modules/,
priority: 20
},
common: {
name: 'common',
minChunks: 2,
chunks: 'all',
priority: 10,
reuseExistingChunk: true,
enforce: true
}
}
},
},
output: {
chunkFilename: '[name].[chunkhash].js',
publicPath: '/',
path: path.join(baseDir, 'dist'),
filename: '[name].[hash].js',
},
module: {
rules: [
{
test: /^.*\.jsx?$/,
include: [path.resolve(baseDir, 'lib')],
loader: 'babel-loader?cacheDirectory',
},
{
test: /\.mjs$/,
include: /node_modules/,
type: 'javascript/auto',
},
],
},
};
module.exports = config;
I think this has the result you are looking for?
image of bunde analyzer showing modules in their own bundles
I think it requires the splitChunks option to actually tree-shake the components properly.
I have spend a lot of time trying to figure webpack out, but I'm still guessing here.

Why does webpack ignore chunk in production mode?

I have multiple entry points that share same code. What I need to do is to extract this code into one file using splitCode in webpack 4. This works fine in development mode but not in production.
Configuration file:
var path = require('path');
const ManifestPlugin = require('webpack-manifest-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const WebpackMd5Hash = require("webpack-md5-hash");
const CleanWebpackPlugin = require('clean-webpack-plugin');
module.exports = {
mode: "development",
module: {
rules: [
{
test: /\.vue$/,
loader: 'vue-loader'
},
{
test: /\.js$/,
exclude: /node_modules/,
use: {
loader: 'babel-loader',
}
},
{
test: /\.(sass|scss|css)$/,
use: [
"style-loader",
MiniCssExtractPlugin.loader,
"css-loader",
"sass-loader"
]
}
],
},
output: {
path: path.join(__dirname, 'public'),
filename: 'js/[name]-[chunkhash].js',
chunkFilename: 'js/[name]-[chunkhash].js',
publicPath: '/'
},
externals: {
jquery: "jQuery"
},
optimization: {
runtimeChunk: "single",
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendor",
chunks: "all",
priority: 1
},
utilities: {
test: /\.s?js$/,
minChunks: 2,
name: "utilities",
chunks: "all",
priority: 0
}
}
}
},
context: path.join(__dirname, 'resources/assets'),
entry: {
a: './js/a.js',
b: './js/b.js'
},
plugins: [
new CleanWebpackPlugin(['public/js/*.*', 'public/css/*.*'], {} ),
new MiniCssExtractPlugin({
filename: "css/[name]-[contenthash].css"
}),
new WebpackMd5Hash(),
new ManifestPlugin({
fileName: 'manifest.json'
}),
]
};
In development mode Webpack creates two entry points, one runtime.js, vendor.js and utilities.js which is ok.
When I change mode from development to production, webpack ignores utilities cacheGroups and appends common codebase into two entry points.
What am I missing?
Webpack version: 4.28.4
Node version: 8.15
It seems like setting enforce to true does the job (but I'm not entirely really sure why).
It should be like this:
utilities: {
test: /\.s?js$/,
minChunks: 2,
name: "utilities",
chunks: "all",
priority: 0,
enforce: true
}
From now on, utilities.js is being created not only in development mode, but also in production.
It's not a bug. It's a feature
Webpack 4 splitchunks.cacheGroups is ignored in production mode IF the new chunk is less than the size of 30kb.
solution to override this default condition:
user splitchunks.cacheGroups.runtime.enforce: true if you want to really make sure that these chunks are created
Check the documention for further details https://webpack.js.org/plugins/split-chunks-plugin/#defaults
Specifying minChunks: 2 means it will only create a split bundle if the given common imports is specified in at least 2 modules. You might want to verify but dropping it to 1.
There are few additional default rules listed here: https://gist.github.com/sokra/1522d586b8e5c0f5072d7565c2bee693#defaults but mainly if the common codebase isn't larger than 30kb (before min+gz) then it won't get split out. You can force it by updated the key minSize as listed in the default optimization config.
Webpack SplitChunksPlugin, by default, ignores any chunk smaller than 30kb. If you run Webpack in development mode, you'll be able to see the bundle size of utilities.js and enforce the split by setting optimization.splitChunks.minSize option smaller than the size of utilities.js.

Is there a way to expose module globally from Webpack DllPlugin vendor bundle?

Using Webpack 4, I'm creating a bundle and a vendors bundle. Vendor bundle contains jQuery and the code bundle references jquery using 'import * from jquery'. This works perfectly.
However, I now need to use a 3rd party (already minified) javascript file. That file expects jquery to be exposed as '$' on the global window object. My bundle needs to be loaded after the new minified file, also.
So I have:
<script src='./libs/jquery-3.2.1.min.js'></script>
<script src='../vendor.js'></script>
<script src="./libs/newMinifiedFile.js"></script>
<script src="../bundle.js"></script>
as my current workaround. The static jquery file links the $ objects to the global namespace, and then the vendor.js file allows me to keep using 'import' in my bundle.
So, how do I only load jquery once, and use it in both ways? This seems to be a slightly different problem than most I've seen online because of how I'm loading things.
Here's a small example of my configs right now:
const config = (isDebug) => {
const isDevBuild = isDebug;
const extractCSS = new MiniCssExtractPlugin({filename: 'vendor.css'});
const sharedConfig = {
mode: isDevBuild ? 'development' : 'production',
stats: { modules: false },
resolve: {
extensions: [ '.js' ]
},
module: {
rules: [
{ test: /\.(png|woff|woff2|eot|ttf|svg)(\?|$)/, use: 'url-loader?limit=100000' },
]
},
entry: {
vendor: [
'jquery'
],
},
output: {
publicPath: 'dist/',
filename: '[name].js',
library: '[name]_[hash]',
},
plugins: [
new webpack.NormalModuleReplacementPlugin(/\/iconv-loader$/, require.resolve('node-noop')) // Workaround for https://github.com/andris9/encoding/issues/16
]
};
const clientBundleConfig = merge(sharedConfig, {
output: {
path: path.join(__dirname, 'wwwroot', 'dist'),
pathinfo: false
},
module: {
rules: [
{
test: /\.css(\?|$)/, include: path.resolve(__dirname, "client"), exclude: [/webviewer/, /redux/, /helpers/],
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
minimize: isDevBuild,
sourceMap: isDevBuild
}
}
]
}
]
},
plugins: [
extractCSS,
new webpack.DllPlugin({
path: path.join(__dirname, 'wwwroot', 'dist', '[name]-manifest.json'),
name: '[name]_[hash]'
})
],
optimization: {
minimize: !isDevBuild,
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
});
And in my normal config I use:
new webpack.DllReferencePlugin({
context: __dirname,
manifest: require(path.join(__dirname, 'wwwroot', 'dist', 'vendor-manifest.json'))
})
So, I can find jquery in the global 'vendor_hash' object, but I can't get it to map it to $ no matter what I try (Most plugins seem to see that $ is never used in any of the code I'm supplying for the bundle and thus don't seem to include it). If I add something like this in my 'entry.js' file then it still doesn't work:
window.$ = window.jQuery = require("jquery");

Exclude unused dynamic modules from bundle using react-loadable

I've got an issue with react-loadable where I've got a large list of components that may or may not be rendered depending on user-generated content. I'm using a switch statement to render the correct ones.
A (simplified) list of user-generated content might look like this:
const content = ['Paragraph', 'Image', 'Paragraph', 'Canvas'];
Now, what I want to do is have ONLY the components that are used enter the bundle. Instead, ALL of them that get included in the following switch case are in the bundle. Why?
const collection = (name) => {
switch(name) {
case 'Paragraph':
return Loadable({
loader: () => import('dynamic-paragraph-component'),
loading(){ return null }
})
case 'Video':
return Loadable({
loader: () => import('dynamic-video-component'),
loading() { return null }
})
// etc
}
}
For example, dynamic-video-component ends up in the bundle even if it's not used. Is there a way to prevent this?
Current webpack setup with Webpack 4
//----------------------------------
//
// Bundler
//
//----------------------------------
import webpack from 'webpack';
import path from 'path';
import { ReactLoadablePlugin } from 'react-loadable/webpack';
module.exports = (files) => {
console.log(files);
return {
mode: 'production',
entry: './src/client/index.js',
output: {
filename: './main.pkgd.js',
chunkFilename: './[name].pkgd.js',
path: path.resolve(__dirname, 'tmp'),
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
options: {
babelrc: false,
presets: [
[
'env',
{
modules: false,
targets: {
browsers: ['last 2 versions'],
},
},
],
'flow',
'react',
],
plugins: [
'transform-class-properties',
'syntax-dynamic-import',
'react-loadable/babel',
],
},
},
],
},
optimization: {
splitChunks: {
cacheGroups: {
default: false,
vendors: false,
// vendor chunk
vendor: {
name: 'vendor',
chunks: 'all',
test: /node_modules/,
priority: 20,
reuseExistingChunk: true,
enforce: true,
},
common: {
name: 'main',
minChunks: 1,
chunks: 'initial',
priority: 10,
reuseExistingChunk: true,
enforce: true,
},
},
},
},
plugins: [
new webpack.DefinePlugin({
__isBrowser__: 'true',
env: {
NODE_ENV: JSON.stringify('production'),
},
}),
new ReactLoadablePlugin({
filename: './tmp/react-loadable.json',
}),
],
};
};
The way you have it set up looks correct, so I'd wager the problem is in your webpack.config.js file.
Assuming you are using Webpack 4, you need to reference the code-splitting docs.
Specifically, make sure you have configured the chunkFilename option. Also, you can add comment directives like /* webpackChunkName: "dynamic-video-component" */ for easier debugging.

Webpack style-loader / css-loader: url() path resolution not working

There are a few SO posts about style-loader and css-loader, but despite this I have not been able to find a solution to my problem.
In short summary, when I #import css files in other css files, and the imported css contains url()s with relative paths, the paths are not resolved correctly.
Basically, the error message shows that Webpack ends up thinking the url() paths in the imported css are relative to src (main entry point), rather than being relative to the css file it it is imported into:
// css-one.scss
#import "./assets/open-iconic-master/font/css/open-iconic-bootstrap.css";
// open-iconic-bootstrap.css
#font-face {
src: url('../fonts/open-iconic.eot');
}
Error:
ERROR in ./src/main.scss
(./node_modules/css-loader??ref--5-1!./node_modules/postcss-loader/src??ref--5-2!./node_modules/sass-loader/lib/loader.js??ref--5-3!./src/main.scss)
Module not found: Error: Can't resolve '../fonts/open-iconic.eot' in
'C:\Users\...\src'
# ./src/main.scss
(./node_modules/css-loader??ref--5-1!./node_modules/postcss-loader/src??ref--5-2!./node_modules/sass-loader/lib/loader.js??ref--5-3!./src/main.scss) 7:106-141 7:172-207 # ./src/main.scss # ./src/index.js
What I Have Tried:
I have tried to use the convertToAbsoluteUrls flag in style-loader
I have tried to turn off all source maps (mentioned in style-loader docs)
My Webpack Config File (loaders are at the bottom):
const path = require('path');
const webpack = require('webpack'); // for webpack built-in plugins
const UglifyJSPlugin = require('uglifyjs-webpack-plugin');
const CleanWebpackPlugin = require('clean-webpack-plugin');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
const CopyWebpackPlugin = require('copy-webpack-plugin');
// const WriteFilePlugin = require('write-file-webpack-plugin');
// const ManifestPlugin = require('webpack-manifest-plugin');
// const InlineManifestWebpackPlugin = require('inline-manifest-webpack-plugin');
// const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;
const PATHS = {
// when using __dirname, resolve and join gives same result,
// because __dirname is absolute path to directory of this file.
// OK to use no slashes,
// both resolve and join adds platform-specific separators by default
src: path.resolve(__dirname, 'src'),
dist: path.resolve(__dirname, 'dist'),
build: path.resolve(__dirname, 'build'),
test: path.resolve(__dirname, 'test')
};
const NAMES = {
// JS FILES
index: 'index',
print: 'print',
// Chrome Extension Development
popup: 'popup',
options: 'options',
background: 'background',
contentScript: 'contentScript',
// FOLDERS
assets: 'assets',
utilities: 'utilities',
images: 'images',
fonts: 'fonts',
include: 'include'
};
const FILE_PATHS = {
// JS
indexJs: `${path.join(PATHS.src, NAMES.index)}.js`,
printJs: `${path.join(PATHS.src, NAMES.print)}.js`,
// Chrome Extension Development
popupJs: `${path.join(PATHS.src, NAMES.popup)}.js`,
optionsJs: `${path.join(PATHS.src, NAMES.options)}.js`,
backgroundJs: `${path.join(PATHS.src, NAMES.background)}.js`,
contentScriptJs: `${path.join(
PATHS.src,
NAMES.include,
NAMES.contentScript
)}.js`,
// HTML
indexHtml: `${path.join(PATHS.src, NAMES.index)}.html`,
printHtml: `${path.join(PATHS.src, NAMES.print)}.html`,
// Chrome Extension Development
popupHtml: `${path.join(PATHS.src, NAMES.popup)}.html`,
optionsHtml: `${path.join(PATHS.src, NAMES.options)}.html`,
backgroundHtml: `${path.join(PATHS.src, NAMES.background)}.html`
};
// Third-party (vendor) libraries to include
// const VENDORS = ['react', 'bootstrap', 'lodash', 'jQuery']; // Relative paths to node_modules
// Note: These are relative
const ASSETS = {
images: path.join(NAMES.assets, NAMES.images),
fonts: path.join(NAMES.assets, NAMES.fonts)
};
// CleanWebpackPlugin config
const pathsToClean = [PATHS.dist, PATHS.build];
const cleanOptions = {
root: __dirname,
exclude: ['shared.js'],
verbose: true,
dry: false
};
// CopyWebpackPlugin config
const copyPattern = [
// {
// from: NAMES.assets,
// to: NAMES.assets
// },
// {
// from: path.join(NAMES.include, 'contentScript.css')
// },
// {
// from: 'manifest.json',
// transform(content, copyPath) {
// // generates the manifest file using the package.json informations
// return Buffer.from(
// JSON.stringify({
// ...JSON.parse(content.toString())
// // description: env.npm_package_description,
// // version: env.npm_package_version
// })
// );
// }
// }
];
const copyOptions = {
// ignore: ['*.js'],
context: PATHS.src
};
module.exports = (env = {}) => {
// webpack injects env variable, into webpack config.
// perfect to check for production.
// remember to specify --env.production in command
// (if in production mode).
const isProduction = env.production === true;
return {
entry: {
index: FILE_PATHS.indexJs
// Chrome Extension Development
// popup: FILE_PATHS.popupJs,
// contentScript: FILE_PATHS.contentScriptJs
// options: FILE_PATHS.optionsJs,
// background: FILE_PATHS.backgroundJs,
// vendor: VENDORS
},
mode: isProduction ? 'production' : 'development',
devtool: isProduction ? 'source-map' : 'inline-source-map',
optimization: {
splitChunks: {
chunks: 'all'
}
},
output: {
filename: isProduction ? '[name].[chunkhash:8].js' : '[name].js',
// chunkFilename determine name of non-entry chunk files,
// for example dynamic imports in the app
chunkFilename: isProduction ? '[name].[chunkhash:8].js' : '[name].js',
path: PATHS.dist
},
plugins: [
// new webpack.SourceMapDevToolPlugin({
// filename: '[file].map',
// exclude: ['vendor', 'runtime']
// }),
new webpack.DefinePlugin({
// specifies environment variable for dependencies.
// does not apply to browser runtime environment
// (process.env is provisioned by Node)
'process.env.NODE_ENV': isProduction ?
JSON.stringify('production') :
JSON.stringify('development')
}),
// new BundleAnalyzerPlugin(),
new CleanWebpackPlugin(pathsToClean, cleanOptions),
new MiniCssExtractPlugin({
// Options similar to the same options in webpackOptions.output
// both options are optional
// does not work with Hot Module Replacement (HMR)
// allows HMR in development (will only use this plugin in production)
filename: isProduction ? '[name].[contenthash].css' : '[name].css',
chunkFilename: isProduction ? '[id].[contenthash].css' : '[id].css'
}),
new webpack.HashedModuleIdsPlugin(),
isProduction ?
new UglifyJSPlugin({
cache: true,
parallel: true,
sourceMap: true // set to true if you want JS source maps
}) :
() => {},
new CopyWebpackPlugin(copyPattern, copyOptions),
// new WriteFilePlugin(),
new HtmlWebpackPlugin({
template: FILE_PATHS.indexHtml,
filename: `${NAMES.index}.html`
})
// new HtmlWebpackPlugin({
// template: FILE_PATHS.popupHtml,
// filename: `${NAMES.popup}.html`,
// excludeChunks: [NAMES.contentScript]
// In dev mode, chunks excluded vendor chunk (which holds CSS).
// Above check fixes it.
// }),
// new HtmlWebpackPlugin({
// filename: `${NAMES.contentScript}.html`,
// excludeChunks: [NAMES.popup, 'runtime'] // Runtime only needed in one HTML
// }),
// new HtmlWebpackPlugin({
// template: FILE_PATHS.optionsHtml,
// filename: `${NAMES.options}.html`,
// chunks: isProduction ? [NAMES.options] : ''
// }),
// new HtmlWebpackPlugin({
// template: FILE_PATHS.backgroundHtml,
// filename: `${NAMES.background}.html`,
// chunks: isProduction ? [NAMES.background] : ''
// }),
// no need for CSS minimization here <-- Done by PostCSS (cssnano)
// new InlineManifestWebpackPlugin(),
// new ManifestPlugin({fileName: 'webpack-manifest.json'}),
],
module: {
rules: [{
test: /\.js$/,
exclude: /(node_modules|bower_components)/,
use: {
loader: 'babel-loader',
options: {
presets: ['#babel/preset-env']
}
}
},
{
test: /\.s?[ac]ss$/,
exclude: /node_modules/,
use: [
isProduction ?
MiniCssExtractPlugin.loader :
{
// creates style nodes from JS strings
loader: 'style-loader',
options: {
sourceMap: true,
convertToAbsoluteUrls: true
}
},
{
// CSS to CommonJS (resolves CSS imports into exported CSS strings)
loader: 'css-loader',
options: {
sourceMap: true,
importLoaders: 2
}
},
{
loader: 'postcss-loader',
options: {
config: {
ctx: {
cssnext: {},
cssnano: {},
autoprefixer: {}
}
},
sourceMap: true
}
},
{
// compiles Sass to CSS
loader: 'sass-loader',
options: {
sourceMap: true
}
}
]
},
{
test: /\.(png|svg|jpg|gif)$/,
use: [{
loader: 'file-loader',
options: {
name: '[name].[hash:4].[ext]',
outputPath: ASSETS.images
}
}]
},
{
test: /\.(woff|woff2|eot|ttf|otf)$/,
use: [{
loader: 'file-loader',
options: {
name: '[name].[hash:4].[ext]',
outputPath: ASSETS.fonts
}
}]
},
{
test: /\.(csv|tsv)$/,
use: ['csv-loader']
},
{
test: /\.xml$/,
use: ['xml-loader']
},
{
test: /\.(html)$/,
use: {
loader: 'html-loader',
options: {
interpolate: 'require',
minimize: true
}
}
}
// {
// test: /\.tsx?$/,
// exclude: /(node_modules|bower_components)/,
// use: 'ts-loader'
// }
]
},
devServer: {
// contentBase: path.join(__dirname, 'dist'),
contentBase: PATHS.dist,
compress: false,
port: 8080,
open: false
}
};
};
it took me around 5 days of work to understand how this webpack mess works. I have to be honest I can say that this is one of those things that I really do not understand why they are "defacto" tools of the moment. I can't understand how difficult it can be just to make the config files work as it should, in gulp took me 1 hour to do the same.
My problem was that all the url() rules (including fonts and images) were being loaded by css-loader as [object Module], and they where exported by file-loader but never loaded, so if I added ?url=false to the css-loader it never copied the files and export them. I have to say this was a totally PITA, but I got it working, and I hope it works for somebody else in the world, this was made with webpack 4.
const webpack = require("webpack");
const HtmlWebpackPlugin = require('html-webpack-plugin');
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const ImageminPlugin = require('imagemin-webpack-plugin').default;
const CopyPlugin = require('copy-webpack-plugin');
module.exports = {
entry: "./src/index.js",
mode: "development",
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules|bower_components)/,
loader: "babel-loader",
options: { presets: ["#babel/env"] }
},
{
test: /\.(gif|png|jpe?g|svg)$/i,
use: [
{
loader: 'image-webpack-loader',
options: {
mozjpeg: {
progressive: true,
quality: 65
},
optipng: {
enabled: false,
},
pngquant: {
quality: [0.65, 0.90],
speed: 4
},
gifsicle: {
interlaced: false,
},
webp: {
quality: 75
},
}
},
{
loader: 'file-loader',
options:{
name: '[name].[ext]',
outputPath: 'images/',
publicPath: 'images/'
}
},
'url-loader?limit=100000'
],
},
{
test: /\.(woff(2)?|ttf|eot)(\?v=\d+\.\d+\.\d+)?$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
outputPath: 'fonts/'
}
}
]
},
{
test: /\.s[ac]ss$/i,
use: [
MiniCssExtractPlugin.loader,
{ loader: 'css-loader?url=false'},
{ loader: 'sass-loader', options: { sourceMap: true } }
],
},
]
},
resolve: { extensions: ["*", ".js", ".jsx"] },
output: {
path: path.resolve(__dirname, "dist/"),
publicPath: "",
filename: "bundle.js"
},
devServer: {
contentBase: path.join(__dirname, "dist/"),
port: 3000,
publicPath: "http://localhost:3000/dist/",
hotOnly: true
},
plugins: [ new MiniCssExtractPlugin(),
new CopyPlugin([{ from: 'src/images/', to: 'images/' }]),
new CopyPlugin([{ from: 'src/fonts/', to: 'fonts/' }]),
new ImageminPlugin({ test: /\.(jpe?g|png|gif|svg)$/i }),
new HtmlWebpackPlugin({
hash: true,
template: './src/index.html',
filename: './index.html' //relative to root of the application
}),
]
};
You can turn off processing of url() rules, btw. I have no idea, why this is a default behavior.
{
loader: 'css-loader',
options: {
...
url: false,
}
},
I was able to solve the problem myself. In case it could help others in the future, please find the solution below.
First of all, if you are using both postcss-loader with the postcss-import plugin, AND css-loader, turn off / delete the postcss-import plugin. You do not need more than one tool that resolves #import rules. This is not really a problem if the order of loaders is correct, but you might as well remove it.
In the sass-loader docs, you can read the following:
Since Sass/libsass does not provide url rewriting, all linked assets
must be relative to the output.
If you're just generating CSS without passing it to the css-loader, it must be relative to your web root.
If you pass the generated CSS on to the css-loader, all urls must be relative to the entry-file (e.g. main.scss).
More likely you will be disrupted by this second issue. It is natural to expect relative references to be resolved against the .scss file in which they are specified (like in regular .css files). Thankfully there are two solutions to this problem:
Add the missing url rewriting using the resolve-url-loader. Place it before the sass-loader in the loader chain.
Library authors usually provide a variable to modify the asset path. bootstrap-sass for example has an $icon-font-path. Check out this working bootstrap example.
I decided to follow bullet two, and add in resolve-url-loader above sass-loader in the Webpack config. It now works as expected.
My final Webpack config (for now) looks like this:
{
test: /\.s?[ac]ss$/,
exclude: /node_modules/,
use: [
isProduction
? MiniCssExtractPlugin.loader
: {
// creates style nodes from JS strings
loader: 'style-loader',
options: {
sourceMap: true,
// convertToAbsoluteUrls: true
}
},
{
// CSS to CommonJS (resolves CSS imports into exported CSS strings)
loader: 'css-loader',
options: {
sourceMap: true,
importLoaders: 2
// url: false,
// import: false
}
},
{
loader: 'postcss-loader',
options: {
config: {
ctx: {
cssnext: {},
cssnano: {},
autoprefixer: {}
}
},
sourceMap: true
}
},
{
loader: 'resolve-url-loader',
options: {
attempts: 1,
sourceMap: true
}
},
{
// compiles Sass to CSS
loader: 'sass-loader',
options: { sourceMap: true }
}
]
},
Side Notes
I noticed that source map paths under "no domain" in Chrome's debugger are repeated. If anyone figures out why, please do share
Remember to include the below side effects in package.json, so tree shaking, which happens in production mode, does not delete the extracted css
"sideEffects": [
".css",
".scss"
],
In webpack 5, you should refrain from using raw-loader and file-loader, instead use asset/source and asset/resource.
{
test: /\.txt$/,
type: 'asset/source',
},
{
test: /\.png$/,
type: 'asset/resource',
},
You can read more about assets on: https://webpack.js.org/guides/asset-modules/. It's described how for Webpack 4 and earlier you should use file-loader, and for Webpack5 and later you should use asset modules.
If anyone is struggling with this using Webpack 5 and is trying to upgrade from css-loader 5 to css-loader 6, you might need to check this issue where the poster has a similar problem to the OP:
With css-loader 5.2.7 the images in the input stylus were embedded as data-
URL in the output CSS. With css-loader 6, the images are instead moved
to the output directory.
See Notes here for how to upgrade to css-loader 6 - the salient points are:
using ~ is deprecated when the esModules option is enabled ... and can
be removed from your code
and
file-loader and url-loader are deprecated, please migrate on asset
modules, since v6 css-loader is generating new URL(...) syntax, it
enables by default built-in assets modules, i.e. type: 'asset' for all
url()
I've therefore done the following:
removed any '~' in my .scss files
i.e.
$font-path: "~/src/fonts" !default;
becomes
$font-path: "/src/fonts" !default;
I've also removed the 'file-loader' module completely.
All NPM packages are now up to date and URLs in CSS are working correctly.
IE is not compatible with new URL()
My issue was that webpack 5 changed everything with "Asset modules" apparently and broke file-loader.
Official docs explain it well: https://webpack.js.org/guides/asset-modules/
TL;DR you can fix it by adding type: 'javascript/auto' to the rule/loader
{
test: /\.(eot|ttf|woff2?)($|\?)/i,
loader: 'file-loader',
options: {
name: '[name]-[sha1:hash:hex:10].[ext]',
esModule: false,
},
type: 'javascript/auto'
},

Categories

Resources