revamp webpack config

* simplify it. Go from six checked-in config files + one local one to
  three checked-in configs + commandline options. I find it less
  confusing to have the options plumbed through fewer layers.

* support developing against a https production server, as described in
  guide/developing-ui.md.

* fix the source map. The sourceMap parameter in prod.config.js as far
  as I can tell evaluated to false when run with production config, and
  anyway UglifyJS seems to be incompatible with the specified
  cheap-module-source-map. Use source-map instead.
This commit is contained in:
Scott Lamb
2020-03-01 15:35:08 -08:00
parent 92266612b5
commit 4b397670a4
7 changed files with 163 additions and 581 deletions

View File

@@ -1,81 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera digital video recorder.
// Copyright (C) 2018 Dolf Starreveld <dolf@starreveld.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const path = require('path');
const Settings = require('./parts/Settings');
/**
* Exports a sub-class of Settings specifically for the Moonfire NVR project.
*
* Gives us a simpler constructor that encapsulates the names of the expected
* settings files.
*
* Provide some convenience member variables:
* config {object} Map of the original settings configuration
* values {object} The values map of the settings that were configured
*
* @type {NVRSettings}
*/
module.exports = class NVRSettings extends Settings {
/**
* Construct an NVRSettings object.
*
* This object will be a subclass of Settings, with some extra functionality.
*
* Initializes the super Settings object with the proper project root
* and named settings files.
*
* @param {object} env "env" object passed to webpack config function
* @param {object} args "args" object passed to webpack config function
* @param {String} projectRoot Project root, defaults to '.' which is
* usually the directory from which you run
* npm or yarn.
*/
constructor(env, args, projectRoot = './') {
super({
projectRoot: path.resolve(projectRoot),
primaryFile: 'settings-nvr.js',
secondaryFile: 'settings-nvr-local.js',
env: env,
args: args,
});
const config = this.settings_config;
// Add some absolute paths that might be relevant
this.settings = Object.assign(this.settings, {
_paths: {
project_root: config.projectRoot,
app_src_dir: path.join(config.projectRoot, this.settings.app_src_dir),
dist_dir: path.join(config.projectRoot, this.settings.dist_dir),
},
});
}
};

View File

@@ -33,72 +33,63 @@
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const NVRSettings = require('./NVRSettings');
module.exports = (env, args) => {
const nvrSettings = new NVRSettings(env, args).settings;
return {
entry: {
nvr: path.join(nvrSettings._paths.app_src_dir, 'index.js'),
},
output: {
filename: '[name].bundle.js',
path: nvrSettings._paths.dist_dir,
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
query: {
presets: ['env', {modules: false}],
},
exclude: /(node_modules|bower_components)/,
include: ['./ui-src'],
module.exports = {
entry: {
nvr: './ui-src/index.js',
},
output: {
filename: '[name].bundle.js',
path: path.resolve('./ui-dist/'),
publicPath: '/',
},
module: {
rules: [
{
test: /\.js$/,
loader: 'babel-loader',
query: {
presets: ['env', {modules: false}],
},
{
test: /\.png$/,
use: ['file-loader'],
},
{
test: /\.ico$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
exclude: /(node_modules|bower_components)/,
include: ['./ui-src'],
},
{
test: /\.png$/,
use: ['file-loader'],
},
{
test: /\.ico$/,
use: [
{
loader: 'file-loader',
options: {
name: '[name].[ext]',
},
],
},
{
// Load css and then in-line in head
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
},
plugins: [
new webpack.IgnorePlugin(/\.\/locale$/),
new HtmlWebpackPlugin({
title: nvrSettings.app_title,
filename: 'index.html',
template: path.join(
nvrSettings._paths.app_src_dir,
'assets',
'index.html'
),
}),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment\/moment\.js$/,
'./min/moment.min.js'
),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment-timezone\/index\.js$/,
'./builds/moment-timezone-with-data-2012-2022.min.js'
),
},
],
},
{
// Load css and then in-line in head
test: /\.css$/,
use: ['style-loader', 'css-loader'],
},
],
};
},
plugins: [
new webpack.IgnorePlugin(/\.\/locale$/),
new HtmlWebpackPlugin({
title: 'Moonfire NVR',
filename: 'index.html',
template: './ui-src/assets/index.html',
}),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment\/moment\.js$/,
'./min/moment.min.js'
),
new webpack.NormalModuleReplacementPlugin(
/node_modules\/moment-timezone\/index\.js$/,
'./builds/moment-timezone-with-data-2012-2022.min.js'
),
],
};

View File

@@ -30,36 +30,57 @@
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const merge = require('webpack-merge');
const webpack = require('webpack');
const NVRSettings = require('./NVRSettings');
const baseConfig = require('./base.config.js');
module.exports = (env, args) => {
const settingsObject = new NVRSettings(env, args);
const nvrSettings = settingsObject.settings;
module.exports = merge(baseConfig, {
stats: {
warnings: true,
},
devtool: 'inline-source-map',
mode: 'development',
optimization: {
minimize: false,
namedChunks: true,
},
devServer: {
inline: true,
port: process.env.MOONFIRE_DEV_PORT || 3000,
host: process.env.MOONFIRE_DEV_HOST,
hot: true,
clientLogLevel: 'info',
proxy: {
'/api': {
target: process.env.MOONFIRE_URL || 'http://localhost:8080/',
return settingsObject.webpackMerge(baseConfig, {
stats: {
warnings: true,
},
devtool: 'inline-source-map',
optimization: {
minimize: false,
namedChunks: true,
},
devServer: {
contentBase: nvrSettings.app_src_dir,
historyApiFallback: true,
inline: true,
port: 3000,
hot: true,
clientLogLevel: 'info',
proxy: {
'/api': `http://${nvrSettings.moonfire.server}:${
nvrSettings.moonfire.port
}`,
// The live stream URLs require WebSockets.
ws: true,
// Change the Host: header so the name-based virtual hosts work
// properly.
changeOrigin: true,
// If the backing host is https, Moonfire NVR will set a 'secure'
// attribute on cookie responses, so that the browser will only send
// them over https connections. This is a good security practice, but
// it means a non-https development proxy server won't work. Strip out
// this attribute in the proxy with code from here:
// https://github.com/chimurai/http-proxy-middleware/issues/169#issuecomment-575027907
// See also discussion in guide/developing-ui.md.
onProxyRes: (proxyRes, req, res) => {
const sc = proxyRes.headers['set-cookie'];
if (Array.isArray(sc)) {
proxyRes.headers['set-cookie'] = sc.map(sc => {
return sc.split(';')
.filter(v => v.trim().toLowerCase() !== 'secure')
.join('; ')
});
}
},
},
},
plugins: [new webpack.HotModuleReplacementPlugin()],
});
};
},
plugins: [new webpack.HotModuleReplacementPlugin()],
});

View File

@@ -1,233 +0,0 @@
// vim: set et sw=2 ts=2:
//
// This file is part of Moonfire NVR, a security camera digital video recorder.
// Copyright (C) 2018 Dolf Starreveld <dolf@starreveld.com>
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// In addition, as a special exception, the copyright holders give
// permission to link the code of portions of this program with the
// OpenSSL library under certain conditions as described in each
// individual source file, and distribute linked combinations including
// the two.
//
// You must obey the GNU General Public License in all respects for all
// of the code used other than OpenSSL. If you modify file(s) with this
// exception, you may extend this exception to your version of the
// file(s), but you are not obligated to do so. If you do not wish to do
// so, delete this exception statement from your version. If you delete
// this exception statement from all source files in the program, then
// also delete it here.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
const path = require('path');
const merge = require('webpack-merge');
/**
* Helper function to require a file and catch errors so we can
* distinguish between failure to find the module and errors in the
* module.
*
* When a require results in errors (as opposed to the file not being
* found), we throw an exception.
*
* If the module that is require-d is a function, it will be executed,
* passing the "env" and "args" parameters to it.
* The function should return a map.
*
* @param {String} requiredPath Path to be passed to require()
* @param {object} env webpack's "env" on invocation
* @param {object} args webpack's "args" on invocation (options)
* @param {Boolean} optional True file not to exist
* @return {object} The module, or {} if not found (optional)
*/
function requireHelper(requiredPath, env, args, optional) {
let module = {};
try {
require.resolve(requiredPath); // Throws if not found
try {
module = require(requiredPath);
if (typeof module === 'function') {
module = module(env, args);
}
// Get owned properties only: now a literal map
module = Object.assign({}, require(requiredPath).settings);
} catch (e) {
throw new Error('Settings file (' + requiredPath + ') has errors.');
}
} catch (e) {
if (!optional) {
throw new Error('Settings file (' + requiredPath + ') not found.');
}
}
const webpackMode = (args ? args.mode : null) || 'none';
const modes = module.webpack_mode || {};
delete module.webpack_mode; // Not modifying original module. We have a copy!
if (webpackMode && modes) {
module = merge(module, modes[webpackMode]);
}
return module;
}
/**
* General purpose settings loading class.
*
* The class first reads a specified file extracting a map object with
* settings. It then attempts to read a second file which, if successfull,
* will be merged to override values from the first.
*
* The module exported in each file must either be a map, in which case
* it is used directly, or a function with no arguments. In the latter case
* it will be called in order to obtain the map.
*
* The intended use is that the first file contains project level settings
* that are checked into a repository. The second file should be for local
* (development) overrides and should not be checked in.
*
* If the primary file is allowed optional and is not found, we still
* attempt to read the secondary, but it is never an error if that file
* does not exist.
*
* Both primary and secondary files may contain a property called webpack_mode
* that, in turn, may contain properties named "development" and
* "production". During loading, if these properties are present, the whole
* "webpack_mode" property is *NOT* delivered in the final result, but the
* sub-property corresponding to webpack's "--mode" argument is merged
* with the configuration object at the top-level. This allows either
* sub-property to override defaults in the settings.
*
* Provide some convenience member variables in the Settings object:
* settings_config {object} object with the arguments to the constructor
* settings {object} The values map of the settings that were configured
*
* In many cases a user of this class will only be intersted in the values
* component. A typical usage patterns would the be:
* <pre><code>
* const Settings = require('Settings');
* const settings = (new Settings()).values;
* </code></pre>
*
* This does make the "config" component of the Settings instance unavailable.
* That can be remedied:
* <pre><code>
* const Settings = require('Settings');
* const _settings = new Settings();
* const settings = _settings.values;
* </code></pre>
*
* Now the config is available as "_settings.config".
*
* @type {NVRSettings}
*/
class Settings {
/**
* Construct the settings object by attempting to read and merge
* both files.
*
* Settings file and alternate or specified as filenames only. They
* are always looked for in the project root directory.
*
* "env", and "args" options are intended to be passed in like so:
* <pre><code>
* const Settings = require('./Settings');
*
* module.exports = (env, args) => {
* const settingsObject = new Settings({ env: env, args: args });
* const settings = settingsObject.settings;
*
* return {
* ... webpack config here, using things like
* ... settings.app_title
* };
* }
* </code></pre>
*
* The Settings object inspects "args.mode" to determine how to overload
* some settings values, and defaults to 'none' if not present.
* Alternatively, null can be passed for "env", and you could pass
* <pre>{ mode: 'development' }</pre> for args (or use 'production').
* Both values will be available later from settingsObject.settings_config
* and using the values from webpack gives full access to everything webpack
* knows.
*
* @param {Boolean} options.optional True if main file is optional
* @param {String} options.projectRoot Path to project root
* @param {String} options.primaryFile Name of main settings file
* @param {String} options.secondaryFile Name of secondary settings file
* @param {String} options.env Environment variables (from webpack)
* @param {String} options.args Arguments (from webpack)
*/
constructor({
optional = false,
projectRoot = './',
primaryFile = 'settings.js',
secondaryFile = 'settings-local.js',
env = null,
args = null,
} = {}) {
if (!projectRoot) {
throw new Error('projectRoot argument for Settings is not set.');
}
// Remember settings, as provided
// eslint-disable-next-line prefer-rest-params
this.settings_config = arguments[0];
// Convert settings file names into absolute paths.
const primaryPath = path.resolve(projectRoot, primaryFile);
const secondaryPath = path.resolve(projectRoot, secondaryFile);
// Check if we can resolve the primary file and if we can, require it.
const _settings = requireHelper(primaryPath, env, args, optional);
// Merge secondary override file, if it exists
this.settings = merge(
_settings,
requireHelper(secondaryPath, env, args, true)
);
}
/**
* Take one or more webpack configurations and merge them.
*
* This uses the webpack-merge functionality, but each argument is subjected
* to some pre-processing.
* - If the argument is a string, a 'require' is performed with it first
* - If the remaining value is a function, it is expected to be like a
* webpack initialization function which gets passed "env" and "args"
* and it is called like that.
* - The remaining value is fed to webpack-merge.
*
* @param {[object]} webpackConfig1 Object representing the config
* @return {[type]} Merged configuration
*/
webpackMerge(...packs) {
const unpack = (webpackConfig) => {
if (
typeof webpackConfig === 'string' ||
webpackConfig instanceof String
) {
webpackConfig = require(webpackConfig);
}
const config = this.settings_config;
if (typeof webpackConfig === 'function') {
return webpackConfig(config.env, config.args);
}
return webpackConfig;
};
return merge(packs.map((p) => unpack(p)));
}
}
module.exports = Settings;

View File

@@ -32,17 +32,15 @@
const webpack = require('webpack');
const CompressionPlugin = require('compression-webpack-plugin');
const NVRSettings = require('./NVRSettings');
const baseConfig = require('./base.config.js');
const merge = require('webpack-merge');
const CleanWebpackPlugin = require('clean-webpack-plugin');
module.exports = (env, args) => {
const settingsObject = new NVRSettings(env, args);
const nvrSettings = settingsObject.settings;
return settingsObject.webpackMerge(baseConfig, {
devtool: 'cheap-module-source-map',
return merge(baseConfig, {
devtool: 'source-map',
mode: 'production',
module: {
rules: [
{
@@ -68,12 +66,7 @@ module.exports = (env, args) => {
new UglifyJsPlugin({
cache: true, // webpack4: default
parallel: true, // webpack4: default
sourceMap:
(args.devtool && /source-?map/.test(args.devtool)) ||
(args.plugins &&
args.plugins.some(
(p) => p instanceof webpack.SourceMapDevToolPlugin
)),
sourceMap: true,
uglifyOptions: {
compress: {
keep_infinity: true, // Do not change to 1/0
@@ -120,9 +113,7 @@ module.exports = (env, args) => {
},
},
plugins: [
new CleanWebpackPlugin([nvrSettings.dist_dir], {
root: nvrSettings._paths.project_root,
}),
new CleanWebpackPlugin(),
new CompressionPlugin({
asset: '[path].gz[query]',
algorithm: 'gzip',