├── .gitignore ├── app ├── admin.js ├── login.js ├── dashboard.js ├── shell.js ├── admin │ └── page.html ├── login │ ├── page.html │ └── page.js ├── loading │ └── page.js ├── error │ └── page.js ├── utils │ └── fetch.js ├── dashboard │ ├── page.html │ └── page.js └── app.js ├── images ├── app-vs-page-shell.png └── app-vs-page-shell.svg ├── package.json ├── README.md └── webpack.config.js /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules 2 | /dist -------------------------------------------------------------------------------- /app/admin.js: -------------------------------------------------------------------------------- 1 | import { bootstrapAsync } from "./app"; 2 | bootstrapAsync("admin"); 3 | -------------------------------------------------------------------------------- /images/app-vs-page-shell.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/webpack/webpack-pwa/HEAD/images/app-vs-page-shell.png -------------------------------------------------------------------------------- /app/login.js: -------------------------------------------------------------------------------- 1 | import * as page from "./login/page"; 2 | import { bootstrap } from "./app"; 3 | bootstrap(page); 4 | -------------------------------------------------------------------------------- /app/dashboard.js: -------------------------------------------------------------------------------- 1 | import * as page from "./dashboard/page"; 2 | import { bootstrap } from "./app"; 3 | bootstrap(page); 4 | -------------------------------------------------------------------------------- /app/shell.js: -------------------------------------------------------------------------------- 1 | import "./loading/page"; 2 | import { bootstrapAsync, getCurrentPage } from "./app"; 3 | bootstrapAsync(getCurrentPage()); 4 | -------------------------------------------------------------------------------- /app/admin/page.html: -------------------------------------------------------------------------------- 1 |

Admin

2 | 3 |

4 | 5 |

6 | 7 |

This is the admin page...

8 | -------------------------------------------------------------------------------- /app/login/page.html: -------------------------------------------------------------------------------- 1 |

Login

2 | 3 |

4 | 5 |

6 | 7 |

This is the login page...

8 | -------------------------------------------------------------------------------- /app/loading/page.js: -------------------------------------------------------------------------------- 1 | export function open(name) { 2 | document.body.innerHTML = "

"; 3 | document.querySelector("h1").innerText = `Loading ${name}...`; 4 | return Promise.resolve(); 5 | } 6 | 7 | export function close() { 8 | return Promise.resolve(); 9 | } -------------------------------------------------------------------------------- /app/error/page.js: -------------------------------------------------------------------------------- 1 | export function open(err) { 2 | document.body.innerHTML = "

Error happened:

"; 3 | document.querySelector("h1").innerText = err.message; 4 | return Promise.resolve(); 5 | } 6 | 7 | export function close() { 8 | return Promise.resolve(); 9 | } 10 | -------------------------------------------------------------------------------- /app/utils/fetch.js: -------------------------------------------------------------------------------- 1 | export function fetchAsBase64(url) { 2 | return fetch(url) 3 | .then(res => res.blob()) 4 | .then(blob => new Promise((resolve, reject) => { 5 | const reader = new FileReader(); 6 | reader.onload = () => { 7 | resolve(reader.result.split(",")[1]); 8 | }; 9 | reader.readAsDataURL(blob); 10 | })); 11 | } 12 | -------------------------------------------------------------------------------- /app/dashboard/page.html: -------------------------------------------------------------------------------- 1 |

Dashboard

2 | 3 |

4 | 5 |

6 | 7 |

8 | 9 |

Here is some random data for you. Fetched from the internet (when you are connected):

10 | 11 |

-------------------------------------------------------------------------------- /app/login/page.js: -------------------------------------------------------------------------------- 1 | import html from "./page.html"; 2 | import { navigate } from "../app"; 3 | 4 | export function open() { 5 | document.body.innerHTML = html; 6 | document.querySelector(".nav-dashboard").addEventListener("click", () => { 7 | navigate("dashboard"); 8 | }); 9 | document.querySelector(".nav-admin").addEventListener("click", () => { 10 | navigate("admin"); 11 | }); 12 | return Promise.resolve(); 13 | } 14 | 15 | export function close() { 16 | return Promise.resolve(); 17 | } 18 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "webpack-pwa", 3 | "private": true, 4 | "version": "1.0.0", 5 | "description": "Example for a super simple PWA with webpack.", 6 | "scripts": { 7 | "start": "webpack-dev-server -d", 8 | "build-page": "webpack -p", 9 | "build-both": "webpack -p --env.output page-shell && webpack -p --env.appShell --env.output app-shell", 10 | "deploy": "rm -rf dist && webpack -p --env.output page-shell && webpack -p --env.appShell --env.output app-shell && gh-pages -d dist", 11 | "build-shell": "webpack -p --env.appShell" 12 | }, 13 | "author": "Tobias Koppers @sokra", 14 | "license": "MIT", 15 | "devDependencies": { 16 | "html-loader": "^0.4.4", 17 | "html-webpack-plugin": "^2.26.0", 18 | "offline-plugin": "^4.5.5", 19 | "webpack": "^2.2.0", 20 | "webpack-dev-server": "^2.0.0" 21 | }, 22 | "dependencies": { 23 | "gh-pages": "^0.12.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /app/dashboard/page.js: -------------------------------------------------------------------------------- 1 | import html from "./page.html"; 2 | import { fetchAsBase64 } from "../utils/fetch"; 3 | import { navigate } from "../app"; 4 | 5 | export function open() { 6 | document.body.innerHTML = html; 7 | document.querySelector(".nav-admin").addEventListener("click", () => { 8 | navigate("admin"); 9 | }); 10 | document.querySelector(".nav-login").addEventListener("click", () => { 11 | navigate("login"); 12 | }); 13 | document.querySelector(".nav-unknown").addEventListener("click", () => { 14 | navigate("unknown"); 15 | }); 16 | const lastResult = localStorage.random; 17 | if(lastResult) 18 | document.querySelector(".content").innerText = `${lastResult} (updating...)`; 19 | return fetchAsBase64("https://httpbin.org/bytes/10").then(res => { 20 | document.querySelector(".content").innerText = localStorage.random = res; 21 | }).catch(err => { 22 | if(lastResult) 23 | document.querySelector(".content").innerText = `${lastResult} (Sorry you are offline, this was the last result)`; 24 | else 25 | document.querySelector(".content").innerText = "Sorry you are offline."; 26 | }); 27 | } 28 | 29 | export function close() { 30 | return Promise.resolve(); 31 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # webpack-pwa 2 | 3 | Super simple webpack PWA(Progressive Web App) example featuring 4 | 5 | * Routing with **On Demand Loading** 6 | * Offline support 7 | * Fetching some data from network 8 | * Two approaches 9 | * App Shell 10 | * Page Shell 11 | * By intent: No framework, only simple JavaScript and DOM 12 | * Yes with `innerHTML` and `innerText` 13 | * Feel free to imagine your favorite framework here. 14 | 15 | ## Build and Run it 16 | 17 | ``` shell 18 | npm install 19 | npm run build-shell 20 | cd dist 21 | npm install node-static -g 22 | static 23 | open http://localhost:8080/dashboard.html 24 | ``` 25 | 26 | This builds the App Shell version. 27 | 28 | To build the Page Shell version: replace `npm run build-shell` with `npm run build-page`. 29 | 30 | ## Architecture 31 | 32 | ![app shell vs page shell](images/app-vs-page-shell.png) 33 | 34 | ### App Shell 35 | 36 | * Total size is smaller 37 | * Initial load requests three files: `login.html`, `shell-1234.js`, `3456.js` 38 | * Initial load needs to load: Shell + Router + content 39 | * The shell is visible earlier than with Page Shell approach. 40 | 41 | ### Page Shell 42 | 43 | * Total size is bigger (i. e. dashboard content is in `dashboard-1234.js` and `4567.js`) 44 | * App takes longer to be offline ready. 45 | * Initial load requests only two files: `login.html`, `login-2345.js` 46 | * Initial load needs to load: Shell + content 47 | * The shell + content is visible earlier than with App Shell approach. 48 | 49 | A hybrid approach can be used where shell and content is separated in two requests (see admin page as example). This makes sense when content is much bigger than shell and shell should be visible earlier. 50 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const OfflinePlugin = require("offline-plugin"); 3 | const HtmlWebpackPlugin = require("html-webpack-plugin"); 4 | 5 | module.exports = ({ appShell, output = "." } = {}) => ({ 6 | entry: appShell ? { 7 | // App Shell has only a single entry point 8 | // this entry point loads pages with import() 9 | shell: "./app/shell.js" 10 | } : { 11 | // Page Shell requires an entry point per page 12 | dashboard: "./app/dashboard.js", 13 | login: "./app/login.js", 14 | admin: "./app/admin.js" 15 | }, 16 | output: { 17 | path: path.resolve(__dirname, "dist", output), 18 | filename: "[name]-[chunkhash].js", 19 | chunkFilename: "[chunkhash].js" 20 | }, 21 | module: { 22 | rules: [ 23 | { 24 | test: /\.html/, 25 | use: "html-loader" 26 | } 27 | ] 28 | }, 29 | plugins: [ 30 | // Generate a HTML page per page 31 | // This could be replace by some server logic or SSR 32 | // For the Page Shell each HTML page need to reference the correct entry point 33 | new HtmlWebpackPlugin({ 34 | filename: "dashboard.html", 35 | chunks: !appShell && ["dashboard"] 36 | }), 37 | new HtmlWebpackPlugin({ 38 | filename: "login.html", 39 | chunks: !appShell && ["login"] 40 | }), 41 | new HtmlWebpackPlugin({ 42 | filename: "admin.html", 43 | chunks: !appShell && ["admin"] 44 | }), 45 | // Offline support 46 | new OfflinePlugin({ 47 | caches: { 48 | main: [ 49 | // These assets don't have a chunk hash. 50 | // SW fetch them on every SW update. 51 | "dashboard.html", 52 | "login.html", 53 | "admin.html" 54 | ], 55 | additional: [ 56 | // All other assets have a chunk hash. 57 | // SW only fetch them once. 58 | // They'll have another name on change. 59 | ":rest:" 60 | ] 61 | }, 62 | // To remove a warning about additional need to have hash 63 | safeToUseOptionalCaches: true, 64 | // "additional" section is fetch only once. 65 | updateStrategy: "changed" 66 | }) 67 | ] 68 | }); -------------------------------------------------------------------------------- /app/app.js: -------------------------------------------------------------------------------- 1 | "use strict"; 2 | 3 | import offlineRuntime from "offline-plugin/runtime"; 4 | 5 | // Router state 6 | let currentPage; 7 | let currentAction; 8 | 9 | // The application shell 10 | // Here it's only a color, but it could be your title bar with logo. 11 | document.body.style.background = "lightblue"; 12 | 13 | // Bootstraping for Page Shell. 14 | // "page" is the already available page 15 | export function bootstrap(page) { 16 | currentPage = page; 17 | currentAction = currentPage.open() 18 | .then(() => { 19 | offlineRuntime.install(); 20 | }); 21 | registerRouter(); 22 | } 23 | 24 | // Bootstrapping for App Shell (or hybrid Page Shell page) 25 | // "pageName" is only the name of the page. 26 | // This page will be loaded while bootstrapping 27 | export function bootstrapAsync(pageName) { 28 | currentAction = Promise.resolve(); 29 | openPage({ 30 | page: pageName 31 | }).then(() => { 32 | offlineRuntime.install(); 33 | }); 34 | registerRouter(); 35 | } 36 | 37 | // Bind router to events (modern browsers only) 38 | function registerRouter() { 39 | window.addEventListener("popstate", event => { 40 | openPage(event.state || { 41 | page: getCurrentPage() 42 | }); 43 | }); 44 | } 45 | 46 | // get current page from URL 47 | export function getCurrentPage() { 48 | var m = /([^\/]+)\.html/.exec(location.pathname); 49 | return m ? m[1] : "unknown"; 50 | } 51 | 52 | // Start loading loading page 53 | //const loadingPage = import("./loading/page"); 54 | 55 | // Router logic for loading and opening a page. 56 | function openPage(state) { 57 | const pageName = state.page; 58 | currentAction = currentAction 59 | // Close the current page 60 | .then(() => currentPage && currentPage.close()) 61 | // Start loading the next page 62 | .then(() => import(`./${pageName}/page`)) 63 | // Display the loading page while loading the next page 64 | /*.then(() => loadingPage 65 | .then(loading => loading.open(pageName) 66 | .then(() => import(`./${pageName}/page`)) 67 | .then(page => loading.close().then(() => page)) 68 | ))*/ 69 | // Open the next page 70 | .then(newPage => { 71 | currentPage = newPage; 72 | return currentPage.open(); 73 | }) 74 | // Display error page 75 | .catch(err => { 76 | return import("./error/page") 77 | .then(newPage => { 78 | currentPage = newPage; 79 | return currentPage.open(err); 80 | }); 81 | }); 82 | return currentAction; 83 | } 84 | 85 | // Router logic, Called by pages 86 | // Starts navigating to another page 87 | export function navigate(pageName) { 88 | const state = { page: pageName }; 89 | window.history.pushState(state, pageName, `${pageName}.html`); 90 | openPage(state); 91 | } 92 | -------------------------------------------------------------------------------- /images/app-vs-page-shell.svg: -------------------------------------------------------------------------------- 1 | 2 | 13 | 15 | 21 | 26 | 27 | 33 | 38 | 39 | 45 | 50 | 51 | 57 | 62 | 63 | 69 | 74 | 75 | 81 | 86 | 87 | 93 | 98 | 99 | 105 | 110 | 111 | 117 | 122 | 123 | 129 | 134 | 135 | 141 | 146 | 147 | 153 | 158 | 159 | 165 | 170 | 171 | 177 | 182 | 183 | 189 | 194 | 195 | 201 | 206 | 207 | 213 | 218 | 219 | 225 | 230 | 231 | 237 | 242 | 243 | 249 | 254 | 255 | 261 | 266 | 267 | 273 | 278 | 279 | 285 | 290 | 291 | 297 | 302 | 303 | 309 | 314 | 315 | 316 | 318 | 319 | 321 | image/svg+xml 322 | 324 | 325 | 326 | 327 | 328 | 330 | 333 | 336 | 343 | 4567.js 353 | 354 | 357 | 364 | 3456.js 374 | 375 | 378 | 385 | 2345.js 395 | 396 | 399 | 406 | shell-1234.js 416 | 417 | App Shell 426 | 429 | 436 | Login Content(app/login/page.js) 450 | 451 | 454 | 461 | Dashboard Content(app/dashboard/page.js) 475 | 476 | 479 | 486 | Admin Content(app/admin/page.js) 500 | 501 | 505 | 509 | 513 | 516 | 523 | App Shell(app/shell.js) 537 | 538 | 542 | 546 | 550 | 553 | 560 | Admin(admin.html) 574 | 575 | 578 | 585 | Login(login.html) 599 | 600 | 603 | 610 | Dashboard(dashboard.html) 624 | 625 | 629 | 635 | 636 | 638 | Page Shell 647 | 650 | 657 | dashboard-1234.js 667 | 668 | 671 | 678 | Dashboard Content(app/dashboard/page.js) 692 | 693 | 697 | 700 | 707 | login-2345.js 717 | 718 | 721 | 728 | Login Content(app/login/page.js) 742 | 743 | 747 | 751 | 757 | 760 | 767 | admin-3456.js 777 | 778 | 782 | 788 | 791 | 798 | 6789.js 808 | 809 | 812 | 819 | 5678.js 829 | 830 | 833 | 840 | 4567.js 850 | 851 | 854 | 861 | Login Content(app/login/page.js) 875 | 876 | 879 | 886 | Dashboard Content(app/dashboard/page.js) 900 | 901 | 904 | 911 | Admin Content(app/admin/page.js) 925 | 926 | 930 | 934 | 938 | 941 | 948 | Login(login.html) 962 | 963 | 966 | 973 | Dashboard(dashboard.html) 987 | 988 | 991 | 998 | Admin(admin.html) 1012 | 1013 | 1017 | 1021 | 1025 | 1029 | 1033 | 1037 | 1041 | 1044 | 1051 | Admin Shell(app/admin.js) 1065 | 1066 | 1069 | 1076 | Login Shell(app/login.js) 1090 | 1091 | 1094 | 1101 | Dashboard Shell(app/dashboard.js) 1115 | 1116 | 1120 | 1126 | hybirdloading content asyncto reduce total size(assuming admin content is big) 1148 | 1149 | load async (extra request to SW) 1159 | 1163 | load sync (in the same bundle) 1173 | 1177 | 1178 | 1179 | --------------------------------------------------------------------------------