Skip to content

Instantly share code, notes, and snippets.

@dobrinov
Last active April 18, 2023 20:13
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save dobrinov/b56c7748a8f38e10873d1473743d27ee to your computer and use it in GitHub Desktop.
Save dobrinov/b56c7748a8f38e10873d1473743d27ee to your computer and use it in GitHub Desktop.
Webpack + Rails (wihthout Webpacker)
yarn add webpack webpack-dev-server webpack-cli webpack-assets-manifest js-yaml sass style-loader sass-loader css-loader mini-css-extract-plugin compression-webpack-plugin

Webpack + Rails (wihthout Webpacker)

This document describes everything needed to integrate Webpack with a Rails project. The Webpack configuration here could be simplified even more if React & Typescript support is not needed.

lib/webpack/asset.rb

module Webpack
  module Asset
    extend self

    def path(name)
      manifest.fetch name
    end

    private

    def manifest
      return @manifest if @manifest && Config.cache_manifest?

      @manifest = read_manifest
    end

    def read_manifest
      manifest_path = Rails.root.join 'public', 'packs', 'manifest.json'

      missing_manifest_error =
        if Config.dev_server? && DevServer.running?
          'Please, wait for Webpack dev server build to finish...'
        else
          'Please, run a Webpack build'
        end

      raise missing_manifest_error unless File.exist? manifest_path

      JSON.parse File.read(manifest_path)
    end
  end
end

lib/webpack/compiler.rb

require 'open3'

module Webpack
  module Compiler
    extend self

    def compile
      logger = Logger.new STDOUT
      logger.info 'Compiling assets...'

      stdout, stderr, status = Open3.capture3 'yarn', 'webpack', '--config', './config/webpack/build.js'

      if status.success?
        logger.info 'Assets compiled successfuly'
      else
        logger.error "Asset compilation failed\n#{[stdout, stderr].delete_if(&:empty?).join("\n\n")}"
        raise stderr
      end

      true
    end
  end
end

lib/webpack/dev_server.rb

module Webpack
  module DevServer
    extend self

    def running?
      raise 'Webpack Dev Server configuration not specified' unless Config.dev_server?

      Socket.tcp(Config.dev_server_host, Config.dev_server_port, connect_timeout: 0.01).close
      true
    rescue Errno::ECONNREFUSED
      false
    end
  end
end

lib/tasks/webpack.rake

namespace :webpack do
  desc 'Compile JavaScript packs using webpack for production with digests'
  task compile: [:environment] do
    Webpack::Compiler.compile
  end
end

if Rake::Task.task_defined? 'assets:precompile'
  Rake::Task['assets:precompile'].enhance ['webpack:compile']
else
  Rake::Task.define_task 'assets:precompile' => ['webpack:compile']
end

app/helpers/application_helper.rb

module ApplicationHelper
  def javascript_pack_tag(name, defer: false, **options)
    if Webpack::Config.dev_server? && !Webpack::DevServer.running?
      raise 'Webpack Dev Server is expected to be running in this environment'
    end

    javascript_include_tag Webpack::Asset.path("#{name}.js"), **options.tap { |o| o[:defer] = defer }
  end

  def stylesheet_pack_tag(name, **options)
    return unless Webpack::Config.extract_css?

    stylesheet_link_tag Webpack::Asset.path("#{name}.css"), **options
  end
  
  # ... rest of file ...
end

config/webpack/common.yml

development:
  cache_build: true
  development_build: true
  dev_server:
    host: localhost
    port: 3035

test:
  cache_build: true
  development_build: true
  dev_server: false

production:
  cache_build: false
  development_build: false
  dev_server: false

config/webpack/env.js

const path = require('path')
const {load} = require('js-yaml')
const {readFileSync} = require('fs')

const common = load(readFileSync(path.resolve(__dirname, './common.yml')), 'utf8')
const environments = Object.keys(common)

const railsEnv = environments.includes(process.env.RAILS_ENV) ? process.env.RAILS_ENV : 'development'

if (!process.env.NODE_ENV) {
  if (railsEnv === 'development' || railsEnv === 'test') {
    process.env.NODE_ENV = 'development'
  } else {
    process.env.NODE_ENV = 'production'
  }
}

const config = common[railsEnv]

const ensure = (obj, field) => {
  if (obj[field] === undefined) {
    throw new Error(`${field} must be specified`)
  }
}

ensure(config, 'cache_build')
ensure(config, 'development_build')
ensure(config, 'dev_server')

const rootPath = path.resolve(__dirname, '../../')

const bundles = {
  application: [
    path.join(rootPath, 'app/javascript/packs/application.js'),
    path.join(rootPath, 'app/javascript/packs/application.scss')
   ],
}

const output = {
  filename: 'js/[name]-[contenthash].js',
  chunkFilename: 'js/[name]-[contenthash].chunk.js',
  hotUpdateChunkFilename: 'js/[id]-[fullhash].hot-update.js',
  path: path.join(rootPath, 'public/packs')
}

module.exports = {
  extractCss: config.extract_css,
  cacheBuild: config.cache_build,
  isDevelopmentBuild: config.development_build,
  devServer: config.dev_server,
  bundles,
  output
}

config/webpack/build.js

const WebpackAssetsManifest = require('webpack-assets-manifest')
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const CompressionPlugin = require('compression-webpack-plugin')
const {cacheBuild, isDevelopmentBuild, bundles, output} = require('./env')

module.exports = {
  mode: isDevelopmentBuild ? 'development' : 'production',
  devtool: isDevelopmentBuild ? 'eval-source-map' : 'source-map',
  cache: cacheBuild
    ? {
        type: 'filesystem',
        buildDependencies: {
          config: [__filename]
        }
      }
    : false,
  entry: bundles,
  output: {...output, publicPath: '/packs/'},
  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader']
      },
      {
        test: /\.(woff2)$/i,
        type: 'asset/inline'
      },
      {
        test: /\.(svg|png|jpg|gif)$/i,
        type: 'asset'
      },
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /(node_modules)/,
        loader: 'babel-loader',
        options: {
          cacheDirectory: cacheBuild,
          cacheCompression: false,
          presets: [
            [
              '@babel/preset-env',
              {
                useBuiltIns: 'entry',
                corejs: '3.8',
                modules: 'auto',
                bugfixes: true,
                loose: true,
                exclude: ['transform-typeof-symbol']
              }
            ],
            '@babel/preset-react',
            ['@babel/preset-typescript', {isTSX: true, allExtensions: true}]
          ],
          plugins: ['@babel/plugin-transform-runtime']
        }
      },
      {
        test: /\.m?js/,
        resolve: {
          fullySpecified: false
        }
      },
      isDevelopmentBuild && {
        test: /\.js$/,
        enforce: 'pre',
        use: ['source-map-loader']
      }
    ].filter(Boolean)
  },
  plugins: [
    new WebpackAssetsManifest({
      writeToDisk: true,
      output: 'manifest.json',
      entrypointsUseAssets: true,
      publicPath: true
    }),
    new MiniCssExtractPlugin({
      filename: 'css/[name].[contenthash].css',
      chunkFilename: 'css/[id].[contenthash].css'
    }),
    !isDevelopmentBuild &&
      new CompressionPlugin({
        algorithm: 'gzip',
        filename: '[path][base].gz',
        test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/
      }),
    !isDevelopmentBuild &&
      new CompressionPlugin({
        algorithm: 'brotliCompress',
        filename: '[path][base].br',
        test: /\.(js|css|html|json|ico|svg|eot|otf|ttf|map)$/
      })
  ].filter(Boolean),
  resolve: {
    extensions: [
      '.tsx',
      '.ts',
      '.jsx',
      '.mjs',
      '.js',
      '.sass',
      '.scss',
      '.css',
      '.png',
      '.svg',
      '.gif',
      '.jpeg',
      '.jpg'
    ],
    plugins: [new TsconfigPathsPlugin()],
    modules: ['app/javascript', 'node_modules']
  }
}

config/webpack/dev-build.js

const WebpackAssetsManifest = require('webpack-assets-manifest')
const TsconfigPathsPlugin = require('tsconfig-paths-webpack-plugin')
const ForkTsCheckerWebpackPlugin = require('fork-ts-checker-webpack-plugin')
const ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin')
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
const {extractCss, cacheBuild, isDevelopmentBuild, devServer, bundles, output} = require('./env')

const analyzeBundle = !!process.env.BUNDLE_ANALYZER

if (!devServer) throw new Error('Webpack dev server configuration is not specified for this environment')
if (extractCss) throw new Error('Webpack dev server expects CSS to be served through the JS bundle')

module.exports = {
  mode: 'development',
  devtool: 'eval-source-map',
  optimization: {
    runtimeChunk: {
      name: 'runtime'
    }
  },
  cache: cacheBuild
    ? {
        type: 'filesystem',
        buildDependencies: {
          config: [__filename]
        }
      }
    : false,
  entry: bundles,
  output: {...output, publicPath: `http://${devServer.host}:${devServer.port}/packs/`},
  module: {
    rules: [
      {
        test: /\.s[ac]ss$/i,
        use: ['style-loader', 'css-loader', 'sass-loader']
      },
      {
        test: /\.(woff2)$/i,
        type: 'asset/inline'
      },
      {
        test: /\.(svg|png|gif|jpg)$/i,
        type: 'asset'
      },
      {
        test: /\.(js|jsx|ts|tsx)$/,
        exclude: /(node_modules)/,
        loader: 'babel-loader',
        options: {
          cacheDirectory: cacheBuild,
          cacheCompression: false,
          presets: [
            [
              '@babel/preset-env',
              {
                useBuiltIns: 'entry',
                corejs: '3.8',
                modules: 'auto',
                bugfixes: true,
                loose: true,
                exclude: ['transform-typeof-symbol']
              }
            ],
            '@babel/preset-react',
            ['@babel/preset-typescript', {isTSX: true, allExtensions: true}]
          ],
          plugins: ['@babel/plugin-transform-runtime', 'react-refresh/babel']
        }
      },
      {
        test: /\.m?js/,
        resolve: {
          fullySpecified: false
        }
      },
      isDevelopmentBuild && {
        test: /\.js$/,
        enforce: 'pre',
        use: ['source-map-loader']
      }
    ].filter(Boolean)
  },
  plugins: [
    new WebpackAssetsManifest({
      writeToDisk: true,
      output: 'manifest.json',
      entrypointsUseAssets: true,
      publicPath: true
    }),
    new ReactRefreshWebpackPlugin(),
    new ForkTsCheckerWebpackPlugin({
      typescript: {
        diagnosticOptions: {
          semantic: true,
          syntactic: true
        }
      }
    }),
    analyzeBundle && new BundleAnalyzerPlugin()
  ].filter(Boolean),
  resolve: {
    extensions: [
      '.tsx',
      '.ts',
      '.jsx',
      '.mjs',
      '.js',
      '.sass',
      '.scss',
      '.css',
      '.png',
      '.svg',
      '.gif',
      '.jpeg',
      '.jpg'
    ],
    plugins: [new TsconfigPathsPlugin()],
    modules: ['app/javascript', 'node_modules']
  },
  devServer: {
    client: {
      overlay: true
    },
    static: {
      publicPath: output.path
    },
    headers: {
      'Access-Control-Allow-Origin': '*'
    },
    compress: true,
    hot: true,
    liveReload: false,
    host: devServer.host,
    port: devServer.port
  },
  watchOptions: {
    aggregateTimeout: 600
  }
}

bin/webpack

#!/bin/bash
yarn webpack --config ./config/webpack/build.js

bin/webpack-dev-server

#!/bin/bash
yarn webpack-dev-server --config ./config/webpack/dev-build.js

bin/webpack-flush

#!/bin/bash
rm -rf public/packs
rm -rf node_modules/.cache
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment