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 -
I will need to use the InjectManifest Plugin
because I want to integrate Push notifications service in my Service Worker
I have no clue how to specifiy the paths for swSrc
and swDest
.
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.
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.
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.
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 theworkbox-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:
service-worker.js
to be included in the build manifest as this will be injected into the precache manifestservice-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):
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)
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');
});
}
workbox
Global, or Module ImportingAs 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
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
"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
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 environmentnpm run <environment>-core
will build just the core application in the specified environmentKnown Issues
npm run watch
. I have been unable to get this to work correctly as of yet"devDependencies": {
"laravel-mix": "^3.0.0"
}
This can also be achieved by running npm install laravel-mix@3.0.0
/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:
app.js
, manifest.js
, and vendor.js
(and dynamic importing)laravel-mix-versionhash
is utilised insteadhtml-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 basedcopy-webpack-plugin
is utilised to copy the static files to the /dist
directory, and to copy any necessary icons to the site rootimagemin-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
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');