digitalfabrik/integreat-app

View on GitHub
web/tools/webpack.config.ts

Summary

Maintainability
D
1 day
Test Coverage
import ReactRefreshPlugin from '@pmmmwh/react-refresh-webpack-plugin'
import AssetsPlugin from 'assets-webpack-plugin'
import { CleanWebpackPlugin } from 'clean-webpack-plugin'
import CopyPlugin from 'copy-webpack-plugin'
import { readFileSync } from 'fs'
import HtmlWebpackPlugin from 'html-webpack-plugin'
import { join, resolve } from 'path'
import ReactRefreshTypeScript from 'react-refresh-typescript'
import { Configuration, DefinePlugin, LoaderOptionsPlugin, optimize, WebpackPluginInstance } from 'webpack'
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer'
import 'webpack-dev-server'

import loadBuildConfig, { WEB } from 'build-configs'
import { WebBuildConfigType } from 'build-configs/BuildConfigType'

// reset the tsconfig to the default configuration
delete process.env.TS_NODE_PROJECT

const SHORT_COMMIT_SHA_LENGTH = 8

// A first performance budget, which should be improved in the future: Maximum bundle size in Bytes; 2^20 = 1 MiB
// eslint-disable-next-line no-magic-numbers
const MiB = 2 ** 20
// eslint-disable-next-line no-magic-numbers
const MAX_BUNDLE_SIZE = 1.64 * MiB
// eslint-disable-next-line no-magic-numbers
const MAX_ASSET_SIZE = 2.1 * MiB

const readJson = (path: string) => JSON.parse(readFileSync(path, 'utf8'))

const readVersionName = () => {
  const versionFile = readJson(resolve(__dirname, '../../version.json'))
  return versionFile.versionName
}

// https://developer.android.com/training/app-links/verify-site-associations#manual-verification
const generateAssetLinks = (buildConfig: WebBuildConfigType) => {
  if (!buildConfig.apps) {
    throw Error('Cannot generate asset links if no apps are available!')
  }

  return JSON.stringify(
    [
      {
        relation: ['delegate_permission/common.handle_all_urls'],
        target: {
          namespace: 'android_app',
          package_name: buildConfig.apps.android.applicationId,
          sha256_cert_fingerprints: [buildConfig.apps.android.sha256CertFingerprint],
        },
      },
      ...buildConfig.allowedLookalikes.map(lookalikeSite => ({
        relation: ['lookalikes/allowlist'],
        target: { namespace: 'web', site: lookalikeSite },
      })),
    ],
    null,
    2,
  )
}

const generateAppleAppSiteAssociation = (buildConfig: WebBuildConfigType) => {
  if (!buildConfig.apps) {
    throw Error('Cannot generate apple app site association if no apps are available!')
  }

  return JSON.stringify(
    {
      applinks: {
        apps: [],
        details: [
          {
            appIDs: buildConfig.apps.ios.appleAppSiteAssociationAppIds,
            paths: ['*', '/'],
          },
        ],
      },
    },
    null,
    2,
  )
}

const generateManifest = (content: Buffer, buildConfig: WebBuildConfigType) => {
  const manifest = JSON.parse(content.toString())

  manifest.version = readVersionName()
  manifest.homepage_url = buildConfig.aboutUrls.default
  manifest.theme_color = buildConfig.lightTheme.colors.themeColor
  manifest.name = buildConfig.appName
  manifest.description = buildConfig.appDescription

  if (buildConfig.apps) {
    manifest.related_applications = [
      {
        platform: 'play',
        id: buildConfig.apps.android.applicationId,
        url: `https://play.google.com/store/apps/details?id=${buildConfig.apps.android.applicationId}`,
      },
      {
        platform: 'itunes',
        url: `https://apps.apple.com/de/app/${buildConfig.apps.ios.appStoreName}/id${buildConfig.apps.ios.appStoreId}`,
      },
    ]
  }
  manifest.short_name = manifest.name
  return JSON.stringify(manifest, null, 2)
}

const createConfig = (
  env: {
    config_name?: string
    dev_server?: boolean
    bundle_analyzer?: boolean
    version_name?: string
    commit_sha?: string
  } = {},
): Configuration => {
  const {
    config_name: buildConfigName,
    commit_sha: passedCommitSha,
    version_name: passedVersionName,
    dev_server: devServer,
    bundle_analyzer: bundleAnalyer,
  } = env

  if (!buildConfigName) {
    throw new Error('Please specify a build config name')
  }

  const buildConfig = loadBuildConfig(buildConfigName, WEB)

  const NODE_ENV = devServer ? '"development"' : '"production"'
  process.env.NODE_ENV = NODE_ENV

  // If version_name is not supplied read it from version file
  const versionName = passedVersionName || readVersionName()
  const shortCommitSha = passedCommitSha?.substring(0, SHORT_COMMIT_SHA_LENGTH) || 'Commit SHA unknown'
  console.log('Used config: ', buildConfigName)
  console.log('Version name: ', versionName)
  console.log('Commit SHA: ', shortCommitSha)
  if (devServer) {
    console.log('Configured for running in dev server')
  }

  const configAssets = resolve(__dirname, `../node_modules/build-configs/${buildConfigName}/assets`)

  const nodeModules = resolve(__dirname, '../node_modules')
  const rootNodeModules = resolve(__dirname, '../../node_modules')
  const wwwDirectory = resolve(__dirname, '../www')
  const distDirectory = resolve(__dirname, `../dist/${buildConfigName}`)
  const srcDirectory = resolve(__dirname, '../src')
  const wellKnownDirectory = resolve(distDirectory, '.well-known')
  const bundleReportDirectory = resolve(__dirname, '../reports/bundle')
  const manifestPreset = resolve(__dirname, 'manifest.json')
  const assetLinksPreset = resolve(__dirname, 'assetlinks.json')
  const appleAppSiteAssociationPreset = resolve(__dirname, 'apple-app-site-association')

  const plugins: WebpackPluginInstance[] = []
  if (devServer) {
    plugins.push(new ReactRefreshPlugin())
  }

  const config: Configuration = {
    mode: devServer ? 'development' : 'production',
    resolve: {
      extensions: ['.tsx', '.ts', '.js'],
      modules: [nodeModules, rootNodeModules],
      alias: {
        'mapbox-gl': 'maplibre-gl',
      },
    },
    // The base directory for resolving the entry option
    context: srcDirectory,
    // The entry point for the bundle
    entry: [
      '!!style-loader!css-loader!normalize.css/normalize.css',
      /* The main entry point of your JavaScript application */
      './index.tsx',
    ],
    // Options affecting the output of the compilation
    output: {
      path: distDirectory,
      publicPath: '/',
      filename: devServer ? '[name].js?[contenthash]' : '[name].[contenthash].js',
      chunkFilename: devServer ? '[id].js?[chunkhash]' : '[id].[chunkhash].js',
      sourcePrefix: '  ',
    },
    optimization: {
      usedExports: true,
      runtimeChunk: 'single',
    },
    devtool: 'source-map',
    devServer: {
      static: { directory: distDirectory },
      compress: true,
      port: 9000,
      host: '0.0.0.0', // This enables devices in the same network to connect to the dev server
      hot: true,
      historyApiFallback: true,
    },
    // What information should be printed to the console
    stats: 'minimal',
    performance: {
      hints: !devServer ? 'error' : false,
      maxEntrypointSize: MAX_BUNDLE_SIZE,
      maxAssetSize: MAX_ASSET_SIZE,
    },
    // The list of plugins for Webpack compiler
    plugins: [
      new BundleAnalyzerPlugin({
        analyzerMode: !bundleAnalyer ? 'disabled' : 'static',
        generateStatsFile: !devServer,
        openAnalyzer: false,
        reportFilename: join(bundleReportDirectory, 'report.html'),
        statsFilename: join(bundleReportDirectory, 'stats.json'),
      }),
      new CleanWebpackPlugin(),
      new HtmlWebpackPlugin({
        title: buildConfig.appName,
        template: 'index.ejs',
        templateParameters: {
          config: buildConfig,
        },
      }),
      new CopyPlugin({
        patterns: [
          { from: wwwDirectory, to: distDirectory },
          { from: configAssets, to: distDirectory },
          {
            from: manifestPreset,
            to: distDirectory,
            transform: (content: Buffer) => generateManifest(content, buildConfig),
          },
          ...(buildConfig.apps
            ? [
                {
                  from: assetLinksPreset,
                  to: wellKnownDirectory,
                  transform: () => generateAssetLinks(buildConfig),
                },
                {
                  from: appleAppSiteAssociationPreset,
                  to: distDirectory,
                  transform: () => generateAppleAppSiteAssociation(buildConfig),
                },
              ]
            : []),
        ],
      }),
      new DefinePlugin({
        'process.env.NODE_ENV': NODE_ENV,
        __VERSION_NAME__: JSON.stringify(versionName),
        __COMMIT_SHA__: JSON.stringify(shortCommitSha),
        __BUILD_CONFIG_NAME__: JSON.stringify(buildConfigName),
        __BUILD_CONFIG__: JSON.stringify(buildConfig),
      }),
      // Emit a JSON file with assets paths
      // https://github.com/sporto/assets-webpack-plugin#options
      new AssetsPlugin({
        path: distDirectory,
        filename: 'assets.json',
        prettyPrint: true,
      }),
      new LoaderOptionsPlugin({
        debug: devServer,
        minimize: !devServer,
      }),
      ...plugins,
    ],
    module: {
      rules: [
        {
          test: /\.tsx?$/,
          exclude: /node_modules/,
          use: [
            {
              loader: 'ts-loader',
              options: {
                transpileOnly: true,
                ...(devServer && {
                  getCustomTransformers: () => ({
                    before: [ReactRefreshTypeScript()],
                  }),
                }),
              },
            },
          ],
        },
        {
          test: /\.html$/,
          use: [
            {
              loader: 'html-loader',
              options: { minimize: true },
            },
          ],
        },
        {
          test: /\.css$/,
          use: ['style-loader', 'css-loader'],
        },
        {
          test: /\.(png|jpg|jpeg|gif|svg|woff|woff2)$/,
          type: 'javascript/auto',
          use: [
            {
              loader: 'url-loader',
              options: {
                limit: 10000,
              },
            },
            {
              loader: 'img-loader',
              options: {
                enabled: !devServer,
                gifsicle: {
                  interlaced: false,
                },
                mozjpeg: {
                  progressive: true,
                  arithmetic: false,
                },
                optipng: false,
                pngquant: {
                  floyd: 0.5,
                  speed: 2,
                },
                svgo: {
                  plugins: [{ removeTitle: true }, { convertPathData: false }],
                },
              },
            },
          ],
        },
        {
          test: /\.(eot|ttf|wav|mp3)$/,
          type: 'assets/resource',
        },
      ],
    },
  }

  if (!devServer) {
    config.plugins?.push(new optimize.AggressiveMergingPlugin())
  }

  return config
}

export default createConfig