Search code examples
laravelprogressive-web-appslaravel-mixworkboxworkbox-webpack-plugin

How to use Laravel Mix and WorkBox?


I'm trying to build a PWA for my app; and have spent almost 48 hours trying to figure out how to make use of Workbox with Laravel Mix. What's ironical is that Google says Workbox is meant to make things easy!

Buh!

Okay, so far I've figured out that -

  1. I will need to use the InjectManifest Plugin because I want to integrate Push notifications service in my Service Worker

  2. I have no clue how to specifiy the paths for swSrc and swDest.

  3. What code should go into my webpack.mix.js and whether I should have a temporary service-worker inside my resources/js folder to create a new service worker inside public/ folder.

Can someone help?

PS: I've read almost every blog and help article; but none talks about reliably using Workbox with Laravel mix. Would really appreciate some help here.


Solution

  • I have done a lot of research into this recently and whilst this may not be a full answer to your question, it should give you, or anyone else visiting this page, enough guidance to get started...

    I will add to this answer as I learn and research more.

    For the purposes of this answer, I will assume your service worker is called service-worker.js, however, you can obviously call it whatever you like.

    Step 1 - Laravel Mix

    Assuming you are using Dynamic Importing in your project (if you aren't, you should be), you will need to downgrade Laravel Mix to version 3. There is an acknowledged bug in Laravel Mix 4 that prevents CSS from bundling correctly and this will not be fixed until Webpack 5 is released.

    In addition, the steps outlined in this answer are specifically configured for Laravel Mix 3.

    Step 2 - Import or ImportScripts

    The second issue to solve is whether to utilise the workbox-webpack-plugin for injecting the workbox global using importScripts or whether you should disable this (using importWorkboxFrom: 'disabled') and just individually import the specific modules you need...

    The documentation states:

    When using a JavaScript bundler, you don't need (and actually shouldn't use) the workbox global or the workbox-sw module, as you can import the individual package files directly.

    This implies that we should be using import instead of injecting the importScripts.

    However, there are various issues here:

    • We do not want service-worker.js to be included in the build manifest as this will be injected into the precache manifest
    • We do not want service-worker.js to be versioned in production i.e. the name should always be service-worker.js, not service-worker.123abc.js.
    • InjectManifest will fail to inject the manifest because the service-worker.js file will not exist at the time that it runs.

    Therefore, in order to utilise import instead of importScripts, we must have two separate webpack (mix) configurations (see conclusion for guidance on how to do this). I am not 100% certain this is correct, but I will update my answer once I have received an answer to either of the following (please support them to increase chance of receiving an answer):

    Step 3 - File Structure

    Assuming you are using InjectManifest and not GenerateSW, you will need to write your own service worker which will have the JS manifest injected into it by the webpack plugin on each build. This, quite simply, means you need to create a file in your source directory that will be used as the service worker.

    Mine is located at src/js/service-worker.js (this will be different if you are building in a full Laravel project, I am simply using Laravel Mix in a standalone app)

    Step 4 - Registering the Service Worker

    There are various ways to do this; some like to inject inline JS into the HTML template, but others, myself included, simply register the service worker at the top of their app.js. Either way, the code should look something along the lines of:

    if ('serviceWorker' in navigator) {
        window.addEventListener('load', function() {
            navigator.serviceWorker.register('/service-worker.js');
        });
    }
    

    Step 5 - Writing your Service Worker; workbox Global, or Module Importing

    As mentioned in the previous quote from the documentation, it is encouraged to import the specifically required modules into your service worker, instead of utilising the workbox global or workbox-sw module.

    For more information on how to use the individual modules, and how to actually write your service worker, see the following documentation:

    https://developers.google.com/web/tools/workbox/guides/using-bundlers

    Conclusion

    Based on all of my research (which is still ongoing), I have taken the following approach outlined below.

    Before reading, please bear in mind that this is configured for a standalone static PWA (i.e. not a full Laravel project).

    /src/service-worker.js (the service worker)

    When using a bundler such as webpack, it is advised to utlilise import to ensure you include only the necessary workbox modules. This is my service worker skeleton:

    import config from '~/config'; // This is where I store project based configurations
    import { setCacheNameDetails } from 'workbox-core';
    import { precacheAndRoute } from 'workbox-precaching';
    import { registerNavigationRoute } from 'workbox-routing';
    
    // Set the cache details
    setCacheNameDetails({
        prefix: config.app.name.replace(/\s+/g, '-').toLowerCase(),
        suffix: config.app.version,
        precache: 'precache',
        runtime: 'runtime',
        googleAnalytics: 'ga'
    });
    
    // Load the assets to be precached
    precacheAndRoute(self.__precacheManifest);
    
    // Ensure all requests are routed to index.html (SPA)
    registerNavigationRoute('/index.html');
    

    /package.json

    Splitting the Mix configuration

    "scripts": {  
      "development": "npm run dev-service-worker && npm run dev-core",  
      "dev": "npm run development",  
      "dev-service-worker": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js --env.mixfile=service-worker.mix",  
      "dev-core": "cross-env NODE_ENV=development node_modules/webpack/bin/webpack.js --progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js --env.mixfile=core.mix",  
      "watch": "npm run dev-core -- --watch",  
      "watch-poll": "npm run watch -- --watch-poll",  
      "production": "npm run prod-service-worker && npm run prod-core",  
      "prod": "npm run production",  
      "prod-service-worker": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js --env.mixfile=service-worker.mix",  
      "prod-core": "cross-env NODE_ENV=production node_modules/webpack/bin/webpack.js --no-progress --hide-modules --config=node_modules/laravel-mix/setup/webpack.config.js --env.mixfile=core.mix"  
    }
    

    Command Explanation

    • All standard commands will work in the same way as usual (i.e. npm run dev etc.). See known issue about npm run watch
    • npm run <environment>-service-worker will build just the service worker in the specified environment
    • npm run <environment>-core will build just the core application in the specified environment

    Known Issues

    • If you are using an html template that utilises the webpack manifest then you may have issues with npm run watch. I have been unable to get this to work correctly as of yet

    Downgrading to Laravel Mix 3

    "devDependencies": {  
        "laravel-mix": "^3.0.0"  
    }
    

    This can also be achieved by running npm install [email protected]

    /static/index.ejs

    This HTML template is used to generate the single page application index.html. This template is dependant on the webpack manifest being injected.

    <!DOCTYPE HTML>
    <html xmlns="http://www.w3.org/1999/xhtml" lang="en" class="no-js">
        <head>
    
            <!-- General meta tags -->
            <meta charset="utf-8">
            <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
            <meta name="description" content="<%= config.meta.description %>">
            <meta name="rating" content="General">
            <meta name="author" content="Sine Macula">
            <meta name="robots" content="index, follow">
            <meta name="format-detection" content="telephone=no">
    
            <!-- Preconnect and prefetch urls -->
            <link rel="preconnect" href="<%= config.api.url %>" crossorigin>
            <link rel="dns-prefetch" href="<%= config.api.url %>">
    
            <!-- Theme Colour -->
            <meta name="theme-color" content="<%= config.meta.theme %>">
    
            <!-- General link tags -->
            <link rel="canonical" href="<%= config.app.url %>">
    
            <!-- Manifest JSON -->
            <link rel="manifest" href="<%= StaticAsset('/manifest.json') %>" crossorigin>
    
    
            <!-- ----------------------------------------------------------------------
            ---- Icon Tags
            ---- ----------------------------------------------------------------------
            ----
            ---- The following will set up the favicons and the apple touch icons to be
            ---- used when adding the app to the homescreen of an iPhone, and to
            ---- display in the head of the browser.
            ----
            ---->
            <!--[if IE]>
                <link rel="shortcut icon" href="<%= StaticAsset('/favicon.ico') %>">
            <![endif]-->
            <link rel="apple-touch-icon" sizes="72x72" href="<%= StaticAsset('/apple-touch-icon-72x72.png') %>">
            <link rel="apple-touch-icon" sizes="120x120" href="<%= StaticAsset('/apple-touch-icon-120x120.png') %>">
            <link rel="apple-touch-icon" sizes="180x180" href="<%= StaticAsset('/apple-touch-icon-180x180.png') %>">
            <link rel="icon" type="image/png" sizes="16x16" href="<%= StaticAsset('/favicon-16x16.png') %>">
            <link rel="icon" type="image/png" sizes="32x32" href="<%= StaticAsset('/favicon-32x32.png') %>">
            <link rel="icon" type="image/png" sizes="192x192"  href="<%= StaticAsset('/android-chrome-192x192.png') %>">
            <link rel="icon" type="image/png" sizes="194x194"  href="<%= StaticAsset('/favicon-194x194.png') %>">
            <link rel="mask-icon" href="<%= StaticAsset('/safari-pinned-tab.svg') %>" color="<%= config.meta.theme %>">
            <meta name="msapplication-TileImage" content="<%= StaticAsset('/mstile-144x144.png') %>">
            <meta name="msapplication-TileColor" content="<%= config.meta.theme %>">
    
    
            <!-- ----------------------------------------------------------------------
            ---- Launch Images
            ---- ----------------------------------------------------------------------
            ----
            ---- Define the launch 'splash' screen images to be used on iOS.
            ----
            ---->
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-640x1136.png') %>" media="(device-width: 320px) and (device-height: 568px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-750x1294.png') %>" media="(device-width: 375px) and (device-height: 667px) and (-webkit-device-pixel-ratio: 2) and (orientation: portrait)">
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-1242x2148.png') %>" media="(device-width: 414px) and (device-height: 736px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-1125x2436.png') %>" media="(device-width: 375px) and (device-height: 812px) and (-webkit-device-pixel-ratio: 3) and (orientation: portrait)">
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-1536x2048.png') %>" media="(min-device-width: 768px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-1668x2224.png') %>" media="(min-device-width: 834px) and (max-device-width: 834px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
            <link rel="apple-touch-startup-image" href="<%= StaticAsset('/assets/images/misc/splash-2048x2732.png') %>" media="(min-device-width: 1024px) and (max-device-width: 1024px) and (-webkit-min-device-pixel-ratio: 2) and (orientation: portrait)">
    
    
            <!-- ----------------------------------------------------------------------
            ---- Application Tags
            ---- ----------------------------------------------------------------------
            ----
            ---- Define the application specific tags.
            ----
            ---->
            <meta name="application-name" content="<%= config.app.name %>">
            <meta name="apple-mobile-web-app-title" content="<%= config.app.name %>">
            <meta name="apple-mobile-web-app-capable" content="yes">
            <meta name="apple-mobile-web-app-status-bar-style" content="<%= config.app.status_bar %>">
            <meta name="mobile-web-app-capable" content="yes">
            <meta name="full-screen" content="yes">
            <meta name="browsermode" content="application">
    
    
            <!-- ----------------------------------------------------------------------
            ---- Social Media and Open Graph Tags
            ---- ----------------------------------------------------------------------
            ----
            ---- The following will create objects for social media sites to read when
            ---- scraping the site.
            ----
            ---->
    
            <!-- Open Graph -->
            <meta property="og:site_name" content="<%= config.app.name %>">
            <meta property="og:url" content="<%= config.app.url %>">
            <meta property="og:type" content="website">
            <meta property="og:title" content="<%= config.meta.title %>">
            <meta property="og:description" content="<%= config.meta.description %>">
            <meta property="og:image" content="<%= StaticAsset('/assets/images/brand/social-1200x630.jpg') %>">
    
            <!-- Twitter -->
            <meta name="twitter:card" content="app">
            <meta name="twitter:site" content="<%= config.app.name %>">
            <meta name="twitter:title" content="<%= config.meta.title %>">
            <meta name="twitter:description" content="<%= config.meta.description %>">
            <meta name="twitter:image" content="<%= StaticAsset('/assets/images/brand/social-440x220.jpg') %>">
    
    
            <!-- ----------------------------------------------------------------------
            ---- JSON Linked Data
            ---- ----------------------------------------------------------------------
            ----
            ---- This will link the website to its associated social media page. This
            ---- adds to the credibility of the website as it allows search engines to
            ---- determine the following of the company via social media
            ----
            ---->
            <script type="application/ld+json">
                {
                    "@context": "http://schema.org",
                    "@type": "Organization",
                    "name": "<%= config.company.name %>",
                    "url": "<%= config.app.url %>",
                    "sameAs": [<%= '"' + Object.values(config.company.social).map(x => x.url).join('","') + '"' %>]
                }
            </script>
    
            <!-- Define the page title -->
            <title><%= config.meta.title %></title>
    
            <!-- Generate the prefetch/preload links -->
            <% webpack.chunks.slice().reverse().forEach(chunk => { %>
                <% chunk.files.forEach(file => { %>
                    <% if (file.match(/\.(js|css)$/)) { %>
                        <link rel="<%= chunk.initial ? 'preload' : 'prefetch' %>" href="<%= StaticAsset(file) %>" as="<%= file.match(/\.css$/) ? 'style' : 'script' %>">
                    <% } %>
                <% }) %>
            <% }) %>
    
            <!-- Include the core styles -->
            <% webpack.chunks.forEach(chunk => { %>
                <% chunk.files.forEach(file => { %>
                    <% if (file.match(/\.(css)$/) && chunk.initial) { %>
                        <link rel="stylesheet" href="<%= StaticAsset(file) %>">
                    <% } %>
                <% }) %>
            <% }) %>
    
        </head>
        <body ontouchstart="">
    
            <!-- No javascript error -->
            <noscript>JavaScript turned off...</noscript>
    
            <!-- The Vue JS app element -->
            <div id="app"></div>
    
            <!-- Include the core scripts -->
            <% webpack.chunks.slice().reverse().forEach(chunk => { %>
                <% chunk.files.forEach(file => { %>
                    <% if (file.match(/\.(js)$/) && chunk.initial) { %>
                        <script type="text/javascript" src="<%= StaticAsset(file) %>"></script>
                    <% } %>
                <% }) %>
            <% }) %>
    
        </body>
    </html>
    

    /service-worker.mix.js (building the service worker)

    This mix configuration will build your Service Worker (service-worker.js), and place it into the root of /dist.

    Note: I like to clean my dist folder each time I build my project, and as this functionality must be run at this stage of the build process, I have included it in the below configuration.

    const mix   = require('laravel-mix');
    const path  = require('path');
    
    // Set the public path
    mix.setPublicPath('dist/');
    
    // Define all the javascript files to be compiled
    mix.js('src/js/service-worker.js', 'dist');
    
    // Load any plugins required to compile the files
    const Dotenv = require('dotenv-webpack');
    const { CleanWebpackPlugin } = require('clean-webpack-plugin');
    
    // Define the required plugins for webpack
    const plugins = [
    
        // Grant access to the environment variables
        new Dotenv,
    
        // Ensure the dist folder is cleaned for each build
        new CleanWebpackPlugin
    
    ];
    
    // Extend the default Laravel Mix webpack configuration
    mix.webpackConfig({
        plugins,
        resolve: {
            alias: {
                '~': path.resolve('')
            }
        }
    });
    
    // Disable mix-manifest.json (remove this for Laravel projects)
    Mix.manifest.refresh = () => void 0;
    

    /core.mix.js (building the application)

    This mix configuration will build your main application and place it in /dist/js.

    There are various key parts of this mix configuration, each of which has been clearly outlined in the comments within. These are the top-level areas:

    • Code splitting to app.js, manifest.js, and vendor.js (and dynamic importing)
    • Laravel Mix versioning does not work as needed for the HTML template so laravel-mix-versionhash is utilised instead
    • html-webpack-plugin is utilised to generate index.html based on the index.ejs template (see above)
    • webpack-pwa-manifest is utilised to generate a manifest based
    • copy-webpack-plugin is utilised to copy the static files to the /dist directory, and to copy any necessary icons to the site root
    • imagemin-webpack-plugin is used to compress any static images in production
    • workbox-webpack-plugin is used to inject the webpack manifest into the precaching array used in the service worker. InjectManifest is used, not GenerateSW
    • Any necessary manifest transformations are applied once the build process is complete

    There may be additions to the above but pretty much everything is described by the comments in the following code:

    const config    = require('./config'); // This is where I store project based configurations
    const mix       = require('laravel-mix');
    const path      = require('path');
    const fs        = require('fs');
    
    // Include any laravel mix plugins
    // NOTE: not needed in Laravel projects
    require('laravel-mix-versionhash');
    
    // Set the public path
    mix.setPublicPath('dist/');
    
    // Define all the SASS files to be compiled
    mix.sass('src/sass/app.scss', 'dist/css');
    
    // Define all the javascript files to be compiled
    mix.js('src/js/app.js', 'dist/js');
    
    // Split the js into bundles
    mix.extract([
        // Define the libraries to extract to `vendor`
        // e.g. 'vue'
    ]);
    
    // Ensure the files are versioned when running in production
    // NOTE: This is not needed in Laravel projects, you simply need
    // run `mix.version`
    if (mix.inProduction()) {
        mix.versionHash({
            length: 8
        });
    }
    
    // Set any necessary mix options
    mix.options({
    
        // This doesn't do anything yet, but once the new version
        // of Laravel Mix is released, this 'should' extract the
        // styles from the Vue components and place them in a
        // css file, as opposed to placing them inline
        //extractVueStyles: true,
    
        // Ensure the urls are not processed
        processCssUrls: false,
    
        // Apply any postcss plugins
        postCss: [
            require('css-declaration-sorter'),
            require('autoprefixer')
        ]
    
    });
    
    // Disable mix-manifest.json
    // NOTE: not needed in Laravel projects
    Mix.manifest.refresh = () => void 0;
    
    // Load any plugins required to compile the files
    const Dotenv                    = require('dotenv-webpack');
    const HtmlWebpackPlugin         = require('html-webpack-plugin');
    const WebpackPwaManifest        = require('webpack-pwa-manifest');
    const { InjectManifest }        = require('workbox-webpack-plugin');
    const CopyWebpackPlugin         = require('copy-webpack-plugin');
    const ImageminPlugin            = require('imagemin-webpack-plugin').default;
    
    // Define the required plugins for webpack
    const plugins = [
    
        // Grant access to the environment variables
        new Dotenv,
    
        // Process and build the html template
        // NOTE: not needed if using Laravel and blade
        new HtmlWebpackPlugin({
            template: path.resolve(__dirname, 'static', 'index.ejs'),
            inject: false,
            minify: !mix.inProduction() ? false : {
                collapseWhitespace: true,
                removeComments: true,
                removeRedundantAttributes: true,
                useShortDoctype: true
            },
            templateParameters: compilation => ({
                webpack: compilation.getStats().toJson(),
                config,
                StaticAsset: (file) => {
                    // This will ensure there are no double slashes (bug in Laravel Mix)
                    return (config.app.static_url + '/' + file).replace(/([^:]\/)\/+/g, "$1");
                }
            })
        }),
    
        // Generate the manifest file
        new WebpackPwaManifest({
            publicPath: '',
            filename: 'manifest.json',
            name: config.app.name,
            description: config.meta.description,
            theme_color: config.meta.theme,
            background_color: config.meta.theme,
            orientation: config.app.orientation,
            display: "fullscreen",
            start_url: '/',
            inject: false,
            fingerprints: false,
            related_applications: [
                {
                    platform: 'play',
                    url: config.app.stores.google.url,
                    id: config.app.stores.google.id
                },
                {
                    platform: 'itunes',
                    url: config.app.stores.apple.url,
                    id: config.app.stores.apple.id
                }
            ],
            // TODO: Update this once the application is live
            screenshots: [
                {
                    src: config.app.static_url + '/assets/images/misc/screenshot-1-720x1280.png',
                    sizes: '1280x720',
                    type: 'image/png'
                }
            ],
            icons: [
                {
                    src: path.resolve(__dirname, 'static/assets/images/icons/android-chrome-512x512.png'),
                    sizes: [72, 96, 128, 144, 152, 192, 384, 512],
                    destination: path.join('assets', 'images', 'icons')
                }
            ]
        }),
    
        // Copy any necessary directories/files
        new CopyWebpackPlugin([
            {
                from: path.resolve(__dirname, 'static'),
                to: path.resolve(__dirname, 'dist'),
                toType: 'dir',
                ignore: ['*.ejs']
            },
            {
                from: path.resolve(__dirname, 'static/assets/images/icons'),
                to: path.resolve(__dirname, 'dist'),
                toType: 'dir'
            }
        ]),
    
        // Ensure any images are optimised when copied
        new ImageminPlugin({
            disable: process.env.NODE_ENV !== 'production',
            test: /\.(jpe?g|png|gif|svg)$/i
        }),
    
        new InjectManifest({
            swSrc: path.resolve('dist/service-worker.js'),
            importWorkboxFrom: 'disabled',
            importsDirectory: 'js'
        })
    
    ];
    
    // Extend the default Laravel Mix webpack configuration
    mix.webpackConfig({
        plugins,
        output: {
            chunkFilename: 'js/[name].js',
        }
    }).then(() => {
    
        // As the precached filename is hashed, we need to read the
        // directory in order to find the filename. Assuming there
        // are no other files called `precache-manifest`, we can assume
        // it is the first value in the filtered array. There is no
        // need to test if [0] has a value because if it doesn't
        // this needs to throw an error
        let filename = fs
            .readdirSync(path.normalize(`${__dirname}/dist/js`))
            .filter(filename => filename.startsWith('precache-manifest'))[0];
    
        // In order to load the precache manifest file, we need to define
        // self in the global as it is not available in node.
        global['self'] = {};
        require('./dist/js/' + filename);
    
        let manifest = self.__precacheManifest;
    
        // Loop through the precache manifest and apply any transformations
        manifest.map(entry => {
    
            // Remove any double slashes
            entry.url = entry.url.replace(/(\/)\/+/g, "$1");
    
            // If the filename is hashed then remove the revision
            if (entry.url.match(/\.[0-9a-f]{8}\./)) {
                delete entry.revision;
            }
    
            // Apply any other transformations or additions here...
    
        });
    
        // Filter out any entries that should not be in the manifest
        manifest = manifest.filter(entry => {
    
            return entry.url.match(/.*\.(css|js|html|json)$/)
                || entry.url.match(/^\/([^\/]+\.(png|ico|svg))$/)
                || entry.url.match(/\/images\/icons\/icon_([^\/]+\.(png))$/)
                || entry.url.match(/\/images\/misc\/splash-([^\/]+\.(png))$/);
    
        });
    
        // Concatenate the contents of the precache manifest and then save
        // the file
        const content = 'self.__precacheManifest = (self.__precacheManifest || []).concat(' + JSON.stringify(manifest) + ');';
        fs.writeFileSync('./dist/js/' + filename, content, 'utf8', () => {});
    
    });
    

    /src/js/app.js (the main application)

    This is where you register your service worker, and obviously define your application etc...

    /**
     * Register the service worker as soon as the page has finished loading.
     */
    if ('serviceWorker' in navigator) {
        window.addEventListener('load', function() {
            // TODO: Broadcast updates of the service worker here...
            navigator.serviceWorker.register('/service-worker.js');
        });
    }
    
    // Define the rest of your application here...
    // e.g. window.Vue = require('vue');