├── .env.template ├── .gitignore ├── .nvmrc ├── .vscode └── settings.json ├── README.md ├── index.js ├── package-lock.json ├── package.json ├── performance-config.js ├── public ├── assets │ ├── css │ │ ├── base.css │ │ ├── colors.css │ │ ├── normalize.css │ │ ├── styles.css │ │ └── typography.css │ ├── img │ │ ├── callout-1.png │ │ ├── callout-2.png │ │ ├── callout-3.png │ │ ├── devstickers-logo.png │ │ ├── fast-sloth-sticker.png │ │ ├── good-day-to-debug-sticker.png │ │ ├── hero-desktop.png │ │ ├── hero-mobile.png │ │ ├── js-happens-sticker.png │ │ └── yo-dawg-sticker.png │ └── js │ │ ├── code_samples.js │ │ ├── logging.js │ │ ├── promo.js │ │ └── scripts.js ├── cart.html ├── index.html └── products │ ├── fast-sloth-sticker.html │ ├── good-day-to-debug-sticker.html │ ├── js-happens-sticker.html │ └── yo-dawg-sticker.html ├── src ├── app.js ├── data │ ├── cartQuery.js │ ├── database.js │ ├── productsQuery.js │ ├── schema.sql │ └── usersQuery.js ├── lib │ └── getRandom.js └── routes │ └── api.js └── tools ├── deploy.sh ├── imagePngOptimize.mjs ├── imagePngResizer.mjs └── imagePngToWebP.mjs /.env.template: -------------------------------------------------------------------------------- 1 | HOST= 2 | BUNNY_PULL_ZONE= 3 | BUNNY_ACCESS_KEY= -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | dist 4 | .env -------------------------------------------------------------------------------- /.nvmrc: -------------------------------------------------------------------------------- 1 | 20.17.0 -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "editor.detectIndentation": false, 3 | "editor.tabSize": 2 4 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Fundamentals of Web Performance Workshop 2 | 3 | Example Online store **Developer Stickers Online** for the Fundamentals of Web Performance Workshop. 4 | 5 | ## Getting Started 6 | 7 | This is a webserver built on NodeJS and Express that servers plain HTML, CSS, JavaScript and Images. You need to have **Node ~20.0** to run this project. 8 | 9 | 1. Install Dependencies `npm install` 10 | 2. Launch the Dev Server `npm start` 11 | 3. Open the website at http://localhost:3000/ 12 | 13 | For demonstration purposes, the website is also hosted: 14 | 15 | - In Amsterdam at http://eu.devstickers.shop:3000/ 16 | - In Amsterdam with HTTP/3 at https://eu.devstickers.shop/ 17 | - Global CDN at https://www.devstickers.shop/ 18 | 19 | ### Instructor Setup 20 | 21 | To do deploys, you need to create the `.env` file from the `.env.template`. You'll also need to enable execute on the script, `chmod +x ./tools/deploy.sh` 22 | 23 | ## Workshop Info 24 | In this one-day training, you'll learn how to make your website *fast*, and keep it that way.You'll learn how to set performance goals for your project, how to measure them, and improve your website performance. 25 |   26 | ### Benefits 27 | - Know why website performance is important for your project and business success on the web. 28 | - Understand the Core Web Vitals and other performance metrics, and the behavior being measured. 29 | - Linking the business goals of your website to performance metrics. 30 | - Technology and tactics to improve your metrics and the user experience of your website. 31 | 32 | ### Summary 33 | Build faster websites and web applications by learning the current metrics and techniques for improving web performance. We’ll look at the psychology of web performance and how users see wait time on your site. Then learn about the new Core Web Vitals that Google uses to measure your page, like Largest Contentful Paint (LCP), Cumulative Layout Shift (CLS), and Interation to Next Paint (INP). Finally, we’ll discuss building a performance culture in your organization to start your applications fast from the beginning! 34 | 35 | ### Course Slides 36 | - [Part 1](https://static.frontendmasters.com/resources/2024-09-26-web-perf-v2/web-perf-v2-slides_part1.pdf) 37 | - [Part 2](https://static.frontendmasters.com/resources/2024-09-26-web-perf-v2/web-perf-v2-slides_part2.pdf) 38 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Launcher 3 | * Fundamentals of Web Performance 4 | * 5 | * Thanks for joining the Fundamentals of Web Performance Workshop! 6 | * 7 | * My goal for this application is to make it a fully-documented example of a 8 | * *very* simple web application using basic tools and libraries. 9 | * 10 | * This is the main launcher, which just spins up the application on our running 11 | * port. 12 | */ 13 | 14 | const app = require("./src/app"); 15 | 16 | app.listen(3000, () => { 17 | console.log("Web server is ready at http://localhost:3000/"); 18 | }); 19 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "fundamentals-of-web-performance", 3 | "version": "1.0.0", 4 | "description": "Example website for the \"Fundamentals of Web Performance\" workshop.", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "nodemon ./index.js", 8 | "bundle": "lightningcss --bundle ./public/assets/css/styles.css -o ./public/assets/css/styles.bundle.css --sourcemap", 9 | "imagePngResizer": "node ./tools/imagePngResizer.mjs", 10 | "imagePngOptimize": "node ./tools/imagePngOptimize.mjs", 11 | "imagePngToWebP": "node ./tools/imagePngToWebP.mjs", 12 | "deploy": "./tools/deploy.sh" 13 | }, 14 | "repository": { 15 | "type": "git", 16 | "url": "git+https://github.com/toddhgardner/fundametals-of-web-performance.git" 17 | }, 18 | "keywords": [ 19 | "web-performance", 20 | "core-web-vitals", 21 | "workshop", 22 | "frontend-masters" 23 | ], 24 | "author": "Todd H. Gardner ", 25 | "license": "", 26 | "bugs": { 27 | "url": "https://github.com/toddhgardner/fundametals-of-web-performance/issues" 28 | }, 29 | "homepage": "https://github.com/toddhgardner/fundametals-of-web-performance#readme", 30 | "dependencies": { 31 | "better-sqlite3": "^11.3.0", 32 | "body-parser": "^2.0.1", 33 | "compression": "^1.7.4", 34 | "cors": "^2.8.5", 35 | "express": "^5.0.0", 36 | "http-compression": "^1.0.20", 37 | "nodemon": "^3.1.6" 38 | }, 39 | "nodemonConfig": { 40 | "ignore": [ 41 | "**/public/**" 42 | ] 43 | }, 44 | "devDependencies": { 45 | "glob": "^11.0.0", 46 | "imagemin": "^9.0.0", 47 | "imagemin-pngquant": "^10.0.0", 48 | "imagemin-webp": "^8.0.0", 49 | "jimp": "^1.6.0", 50 | "lightningcss-cli": "^1.27.0" 51 | } 52 | } -------------------------------------------------------------------------------- /performance-config.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Performance Config 3 | * Fundamentals of Web Performance 4 | * 5 | * These are the configuration options we will experiment with in the workshop. 6 | * They have been extracted to this file for simplicity so you don't have to understand 7 | * the workings of the server specifics. 8 | */ 9 | module.exports = { 10 | 11 | /** 12 | * Whether to use basic GZip Compression on response bodies, when supported by 13 | * the requesting browser. 14 | * @see https://developer.mozilla.org/en-US/docs/Glossary/gzip_compression 15 | */ 16 | enableGzipCompression: false, 17 | 18 | /** 19 | * Whether to use the Brotli Compression on response bodies, when supported 20 | * by the requesting browser. 21 | * @see https://developer.mozilla.org/en-US/docs/Glossary/Brotli_compression 22 | */ 23 | enableBrotliCompression: false, 24 | 25 | /** 26 | * Whether to send the 304 Caching Headers `ETag` and `Last-Modified` for 27 | * static content. 28 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/ETag 29 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Last-Modified 30 | */ 31 | enable304CachingHeaders: false, 32 | 33 | /** 34 | * Whether to send the Browser Caching Headers `Cache-Control` and `Expires` 35 | * headers to maintaining the in-browser cache. 36 | * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cache-Control 37 | */ 38 | enableBrowserCache: false, 39 | 40 | /** 41 | * The expected processing time in milliseconds of a "real" server under load 42 | * that has to talk to external systems. 43 | */ 44 | serverDuration: 1_000 45 | 46 | }; 47 | -------------------------------------------------------------------------------- /public/assets/css/base.css: -------------------------------------------------------------------------------- 1 | @import "./colors.css"; 2 | @import "./normalize.css"; 3 | @import "./typography.css"; 4 | 5 | * { 6 | box-sizing: border-box; 7 | margin: 0; 8 | padding: 0; 9 | } 10 | 11 | body { 12 | background-color: var(--color-background); 13 | color: var(--color-text); 14 | min-height: 100vh; 15 | display: flex; 16 | flex-direction: column; 17 | } 18 | 19 | button { 20 | border: none; 21 | background: transparent; 22 | color: var(--color-primary-400); 23 | padding: 6px 12px; 24 | display: flex; 25 | align-items: center; 26 | justify-content: center; 27 | text-align: center; 28 | border-radius: 3em; 29 | text-decoration: none; 30 | cursor: pointer; 31 | font-size: 1em; 32 | line-height: 1.5em; 33 | font-weight: bold; 34 | transition: filter 200ms ease-in-out; 35 | 36 | &:active, &:focus, &:hover { 37 | filter: brightness(130%); 38 | } 39 | 40 | &.btn-big { 41 | padding: 14px 28px; 42 | font-size: 1.3em; 43 | } 44 | 45 | &.btn-primary { 46 | background-color: var(--color-primary-500); 47 | color: var(--color-surface-mixed-100); 48 | } 49 | 50 | &.btn-inverse { 51 | background-color: var(--color-surface-100); 52 | color: white; 53 | } 54 | 55 | &[disabled=disabled] { 56 | background-color: var(--color-surface-600); 57 | color: var(--color-surface-300); 58 | } 59 | } 60 | 61 | .container { 62 | width: 100%; 63 | padding-right: 15px; 64 | padding-left: 15px; 65 | margin-right: auto; 66 | margin-left: auto; 67 | display: flex; 68 | flex-direction: column; 69 | max-width: 1100px; 70 | min-width: 380px; 71 | 72 | &.flex-row { 73 | flex-direction: row; 74 | } 75 | } 76 | 77 | .flex { 78 | display: flex; 79 | } 80 | 81 | .flex-column { 82 | display: flex; 83 | flex-direction: column; 84 | } 85 | 86 | .flex-gap { 87 | gap: 20px; 88 | } 89 | .flex-gap-10 { 90 | gap: 10px; 91 | } 92 | 93 | .align-center { 94 | align-items: center; 95 | } 96 | 97 | .justify-center { 98 | justify-content: center; 99 | } 100 | 101 | .space-between { 102 | justify-content: space-between; 103 | } 104 | 105 | .mb-20 { 106 | margin-bottom: 20px; 107 | } -------------------------------------------------------------------------------- /public/assets/css/colors.css: -------------------------------------------------------------------------------- 1 | /* Dark Theme Color Palette for Dev Stickers Store */ 2 | /* https://colorffy.com/dark-theme-generator */ 3 | /* Background and Surface */ 4 | :root { 5 | /** CSS DARK THEME PRIMARY COLORS */ 6 | --color-primary-100: #00bcd4; 7 | --color-primary-200: #47c4d9; 8 | --color-primary-300: #67cbde; 9 | --color-primary-400: #81d3e2; 10 | --color-primary-500: #99dae7; 11 | --color-primary-600: #afe2ec; 12 | /** CSS DARK THEME SURFACE COLORS */ 13 | --color-surface-100: #121212; 14 | --color-surface-200: #282828; 15 | --color-surface-300: #3f3f3f; 16 | --color-surface-400: #575757; 17 | --color-surface-500: #717171; 18 | --color-surface-600: #8b8b8b; 19 | /** CSS DARK THEME MIXED SURFACE COLORS */ 20 | --color-surface-mixed-100: #192122; 21 | --color-surface-mixed-200: #2e3637; 22 | --color-surface-mixed-300: #454c4c; 23 | --color-surface-mixed-400: #5d6363; 24 | --color-surface-mixed-500: #767b7b; 25 | --color-surface-mixed-600: #8f9494; 26 | 27 | /** APPLIED COLORS */ 28 | --color-background: var(--color-surface-100); 29 | --color-surface: var(--color-surface-mixed-300); 30 | --color-text: white; 31 | --color-border: var(--color-surface-mixed-400); 32 | --color-primary: var(--color-primary-400); 33 | --color-error: red; 34 | } 35 | -------------------------------------------------------------------------------- /public/assets/css/normalize.css: -------------------------------------------------------------------------------- 1 | /*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */ 2 | 3 | /* Document 4 | ========================================================================== */ 5 | 6 | /** 7 | * 1. Correct the line height in all browsers. 8 | * 2. Prevent adjustments of font size after orientation changes in iOS. 9 | */ 10 | 11 | html { 12 | line-height: 1.15; /* 1 */ 13 | -webkit-text-size-adjust: 100%; /* 2 */ 14 | } 15 | 16 | /* Sections 17 | ========================================================================== */ 18 | 19 | /** 20 | * Remove the margin in all browsers. 21 | */ 22 | 23 | body { 24 | margin: 0; 25 | } 26 | 27 | /** 28 | * Render the `main` element consistently in IE. 29 | */ 30 | 31 | main { 32 | display: block; 33 | } 34 | 35 | /** 36 | * Correct the font size and margin on `h1` elements within `section` and 37 | * `article` contexts in Chrome, Firefox, and Safari. 38 | */ 39 | 40 | h1 { 41 | font-size: 2em; 42 | margin: 0.67em 0; 43 | } 44 | 45 | /* Grouping content 46 | ========================================================================== */ 47 | 48 | /** 49 | * 1. Add the correct box sizing in Firefox. 50 | * 2. Show the overflow in Edge and IE. 51 | */ 52 | 53 | hr { 54 | box-sizing: content-box; /* 1 */ 55 | height: 0; /* 1 */ 56 | overflow: visible; /* 2 */ 57 | } 58 | 59 | /** 60 | * 1. Correct the inheritance and scaling of font size in all browsers. 61 | * 2. Correct the odd `em` font sizing in all browsers. 62 | */ 63 | 64 | pre { 65 | font-family: monospace, monospace; /* 1 */ 66 | font-size: 1em; /* 2 */ 67 | } 68 | 69 | /* Text-level semantics 70 | ========================================================================== */ 71 | 72 | /** 73 | * Remove the gray background on active links in IE 10. 74 | */ 75 | 76 | a { 77 | background-color: transparent; 78 | } 79 | 80 | /** 81 | * 1. Remove the bottom border in Chrome 57- 82 | * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari. 83 | */ 84 | 85 | abbr[title] { 86 | border-bottom: none; /* 1 */ 87 | text-decoration: underline; /* 2 */ 88 | text-decoration: underline dotted; /* 2 */ 89 | } 90 | 91 | /** 92 | * Add the correct font weight in Chrome, Edge, and Safari. 93 | */ 94 | 95 | b, 96 | strong { 97 | font-weight: bolder; 98 | } 99 | 100 | /** 101 | * 1. Correct the inheritance and scaling of font size in all browsers. 102 | * 2. Correct the odd `em` font sizing in all browsers. 103 | */ 104 | 105 | code, 106 | kbd, 107 | samp { 108 | font-family: monospace, monospace; /* 1 */ 109 | font-size: 1em; /* 2 */ 110 | } 111 | 112 | /** 113 | * Add the correct font size in all browsers. 114 | */ 115 | 116 | small { 117 | font-size: 80%; 118 | } 119 | 120 | /** 121 | * Prevent `sub` and `sup` elements from affecting the line height in 122 | * all browsers. 123 | */ 124 | 125 | sub, 126 | sup { 127 | font-size: 75%; 128 | line-height: 0; 129 | position: relative; 130 | vertical-align: baseline; 131 | } 132 | 133 | sub { 134 | bottom: -0.25em; 135 | } 136 | 137 | sup { 138 | top: -0.5em; 139 | } 140 | 141 | /* Embedded content 142 | ========================================================================== */ 143 | 144 | /** 145 | * Remove the border on images inside links in IE 10. 146 | */ 147 | 148 | img { 149 | border-style: none; 150 | } 151 | 152 | /* Forms 153 | ========================================================================== */ 154 | 155 | /** 156 | * 1. Change the font styles in all browsers. 157 | * 2. Remove the margin in Firefox and Safari. 158 | */ 159 | 160 | button, 161 | input, 162 | optgroup, 163 | select, 164 | textarea { 165 | font-family: inherit; /* 1 */ 166 | font-size: 100%; /* 1 */ 167 | line-height: 1.15; /* 1 */ 168 | margin: 0; /* 2 */ 169 | } 170 | 171 | /** 172 | * Show the overflow in IE. 173 | * 1. Show the overflow in Edge. 174 | */ 175 | 176 | button, 177 | input { /* 1 */ 178 | overflow: visible; 179 | } 180 | 181 | /** 182 | * Remove the inheritance of text transform in Edge, Firefox, and IE. 183 | * 1. Remove the inheritance of text transform in Firefox. 184 | */ 185 | 186 | button, 187 | select { /* 1 */ 188 | text-transform: none; 189 | } 190 | 191 | /** 192 | * Correct the inability to style clickable types in iOS and Safari. 193 | */ 194 | 195 | button, 196 | [type="button"], 197 | [type="reset"], 198 | [type="submit"] { 199 | -webkit-appearance: button; 200 | } 201 | 202 | /** 203 | * Remove the inner border and padding in Firefox. 204 | */ 205 | 206 | button::-moz-focus-inner, 207 | [type="button"]::-moz-focus-inner, 208 | [type="reset"]::-moz-focus-inner, 209 | [type="submit"]::-moz-focus-inner { 210 | border-style: none; 211 | padding: 0; 212 | } 213 | 214 | /** 215 | * Restore the focus styles unset by the previous rule. 216 | */ 217 | 218 | button:-moz-focusring, 219 | [type="button"]:-moz-focusring, 220 | [type="reset"]:-moz-focusring, 221 | [type="submit"]:-moz-focusring { 222 | outline: 1px dotted ButtonText; 223 | } 224 | 225 | /** 226 | * Correct the padding in Firefox. 227 | */ 228 | 229 | fieldset { 230 | padding: 0.35em 0.75em 0.625em; 231 | } 232 | 233 | /** 234 | * 1. Correct the text wrapping in Edge and IE. 235 | * 2. Correct the color inheritance from `fieldset` elements in IE. 236 | * 3. Remove the padding so developers are not caught out when they zero out 237 | * `fieldset` elements in all browsers. 238 | */ 239 | 240 | legend { 241 | box-sizing: border-box; /* 1 */ 242 | color: inherit; /* 2 */ 243 | display: table; /* 1 */ 244 | max-width: 100%; /* 1 */ 245 | padding: 0; /* 3 */ 246 | white-space: normal; /* 1 */ 247 | } 248 | 249 | /** 250 | * Add the correct vertical alignment in Chrome, Firefox, and Opera. 251 | */ 252 | 253 | progress { 254 | vertical-align: baseline; 255 | } 256 | 257 | /** 258 | * Remove the default vertical scrollbar in IE 10+. 259 | */ 260 | 261 | textarea { 262 | overflow: auto; 263 | } 264 | 265 | /** 266 | * 1. Add the correct box sizing in IE 10. 267 | * 2. Remove the padding in IE 10. 268 | */ 269 | 270 | [type="checkbox"], 271 | [type="radio"] { 272 | box-sizing: border-box; /* 1 */ 273 | padding: 0; /* 2 */ 274 | } 275 | 276 | /** 277 | * Correct the cursor style of increment and decrement buttons in Chrome. 278 | */ 279 | 280 | [type="number"]::-webkit-inner-spin-button, 281 | [type="number"]::-webkit-outer-spin-button { 282 | height: auto; 283 | } 284 | 285 | /** 286 | * 1. Correct the odd appearance in Chrome and Safari. 287 | * 2. Correct the outline style in Safari. 288 | */ 289 | 290 | [type="search"] { 291 | -webkit-appearance: textfield; /* 1 */ 292 | outline-offset: -2px; /* 2 */ 293 | } 294 | 295 | /** 296 | * Remove the inner padding in Chrome and Safari on macOS. 297 | */ 298 | 299 | [type="search"]::-webkit-search-decoration { 300 | -webkit-appearance: none; 301 | } 302 | 303 | /** 304 | * 1. Correct the inability to style clickable types in iOS and Safari. 305 | * 2. Change font properties to `inherit` in Safari. 306 | */ 307 | 308 | ::-webkit-file-upload-button { 309 | -webkit-appearance: button; /* 1 */ 310 | font: inherit; /* 2 */ 311 | } 312 | 313 | /* Interactive 314 | ========================================================================== */ 315 | 316 | /* 317 | * Add the correct display in Edge, IE 10+, and Firefox. 318 | */ 319 | 320 | details { 321 | display: block; 322 | } 323 | 324 | /* 325 | * Add the correct display in all browsers. 326 | */ 327 | 328 | summary { 329 | display: list-item; 330 | } 331 | 332 | /* Misc 333 | ========================================================================== */ 334 | 335 | /** 336 | * Add the correct display in IE 10+. 337 | */ 338 | 339 | template { 340 | display: none; 341 | } 342 | 343 | /** 344 | * Add the correct display in IE 10. 345 | */ 346 | 347 | [hidden] { 348 | display: none; 349 | } -------------------------------------------------------------------------------- /public/assets/css/styles.css: -------------------------------------------------------------------------------- 1 | @import "./base.css"; 2 | 3 | header { 4 | background-color: var(--color-surface-mixed-300); 5 | 6 | .container { 7 | height: 60px; 8 | * { 9 | max-height: 60px; 10 | } 11 | } 12 | 13 | .site-logo { 14 | .site-icon { 15 | 16 | img { 17 | width: 100%; 18 | height: auto; 19 | } 20 | } 21 | } 22 | 23 | h1 { 24 | font-size: 1.4em; 25 | margin: 0; 26 | a { 27 | gap: 2px; 28 | } 29 | em { 30 | color: var(--color-primary-600); 31 | text-transform: uppercase; 32 | font-size: 0.9em; 33 | margin-top: -5px; 34 | } 35 | } 36 | 37 | a { 38 | color: var(--color-text); 39 | text-decoration: none; 40 | } 41 | 42 | .cart { 43 | a { 44 | gap: 2px; 45 | } 46 | #cart-count { 47 | background-color: var(--color-primary-600); 48 | color: var(--color-surface-200); 49 | width: 28px; 50 | display: flex; 51 | height: 28px; 52 | justify-content: center; 53 | border-radius: 100%; 54 | font-size: 1.2em; 55 | font-weight: bold; 56 | align-items: center; 57 | } 58 | } 59 | } 60 | 61 | footer { 62 | padding: 20px 0; 63 | margin-top: auto; 64 | background-color: var(--color-surface-200); 65 | 66 | p { 67 | color: var(--color-surface-600); 68 | font-size: 0.9em; 69 | } 70 | a { 71 | color: var(--color-surface-600); 72 | transition: color 100ms ease-in-out; 73 | text-decoration: underline; 74 | &:hover, &:active, &:focus { 75 | color: var(--color-primary-400); 76 | } 77 | } 78 | p+p { 79 | margin-top: 10px; 80 | } 81 | 82 | } 83 | 84 | main { 85 | margin-bottom: 40px; 86 | } 87 | 88 | .hero { 89 | position: relative; 90 | overflow: hidden; 91 | width: 100%; 92 | margin-bottom: 40px; 93 | 94 | .illustration { 95 | height: 60vh; 96 | img { 97 | width: 100%; 98 | height: 100%; 99 | object-fit: cover; 100 | 101 | &.desktop { 102 | display: none; 103 | } 104 | 105 | @media(min-width: 900px) { 106 | &.mobile { 107 | display: none; 108 | } 109 | &.desktop { 110 | display: block; 111 | } 112 | } 113 | } 114 | } 115 | 116 | .overlay { 117 | position: absolute; 118 | inset: 0 0 0 0; 119 | background-image: linear-gradient(180deg, transparent, transparent 20%, var(--color-surface-100) 80%); 120 | @media(min-width: 900px) { 121 | background-image: linear-gradient(90deg, transparent, transparent 20%, var(--color-surface-100) 80%); 122 | } 123 | } 124 | 125 | .hero-content { 126 | position: absolute; 127 | left: -5vw; 128 | right: -5vw; 129 | bottom: 5%; 130 | font-size: 1.2em; 131 | background: white; 132 | color: var(--color-surface-100); 133 | transform: rotate(355deg); 134 | /* text-shadow: 0 0 10px black; */ 135 | .container { 136 | /* flex-direction: column-reverse; */ 137 | text-align: center; 138 | height: 100%; 139 | padding: 20px; 140 | max-width: 700px; 141 | } 142 | h1 { 143 | margin: 0 0 5px 0; 144 | } 145 | p { 146 | font-size: 1.2em; 147 | 148 | } 149 | 150 | 151 | } 152 | 153 | } 154 | 155 | .product-grid { 156 | display: grid; 157 | grid-template-columns: repeat(1, minmax(0, 1fr)); 158 | gap: 20px; 159 | margin-bottom: 40px; 160 | .product-card { 161 | flex-direction: column; 162 | text-align: center; 163 | a { 164 | flex-direction: column; 165 | gap: 10px; 166 | } 167 | } 168 | 169 | @media (min-width: 720px) { 170 | grid-template-columns: repeat(2, minmax(0, 1fr)); 171 | } 172 | @media (min-width: 1000px) { 173 | grid-template-columns: repeat(4, minmax(0, 1fr)); 174 | } 175 | } 176 | 177 | .product-list { 178 | .product-card { 179 | flex-direction: row; 180 | a { 181 | flex-direction: row; 182 | max-height: 120px; 183 | gap: 20px; 184 | min-width: 0; 185 | img { 186 | height: 100%; 187 | width: auto; 188 | } 189 | } 190 | } 191 | } 192 | 193 | .product-card { 194 | display: flex; 195 | flex-direction: column; 196 | min-width: 0; 197 | background-color: var(--color-surface-mixed-200); 198 | border-radius: 1em; 199 | padding: 20px; 200 | a { 201 | display: flex; 202 | flex-direction: column; 203 | color: var(--color-text); 204 | text-decoration: none; 205 | align-items: center; 206 | 207 | img { 208 | border-radius: 1em; 209 | width: 100%; 210 | height: auto; 211 | } 212 | } 213 | 214 | button { 215 | margin-top: auto; 216 | } 217 | 218 | transition: box-shadow, transform 200ms ease-in-out; 219 | &:hover, &:active, &:focus { 220 | transform: scale(1.01); 221 | box-shadow: 0 0 10px 2px var(--color-primary-100); 222 | } 223 | } 224 | 225 | .product-detail { 226 | display: flex; 227 | flex-direction: column; 228 | margin: 40px 0; 229 | 230 | picture { 231 | 232 | img { 233 | width: 100%; 234 | height: auto; 235 | } 236 | } 237 | 238 | .product-info { 239 | flex: auto; 240 | display: flex; 241 | flex-direction: column; 242 | text-align: center; 243 | background-color: var(--color-surface-mixed-200); 244 | border-radius: 1em; 245 | padding: 20px; 246 | 247 | p { 248 | font-size: 1.2em; 249 | margin-bottom: 20px; 250 | } 251 | 252 | button { 253 | margin-top: auto; 254 | } 255 | } 256 | 257 | @media(min-width: 720px) { 258 | padding: 20px 0; 259 | flex-direction: row; 260 | 261 | picture { 262 | flex: 0 0 50%; 263 | } 264 | .product-info { 265 | text-align: left; 266 | } 267 | } 268 | } 269 | 270 | .callout { 271 | display: flex; 272 | flex-direction: column; 273 | margin: 40px 0; 274 | 275 | picture { 276 | margin-bottom: 5px; 277 | img { 278 | width: 100%; 279 | height: auto; 280 | border-radius: 1em; 281 | } 282 | } 283 | .callout-text { 284 | text-align: center; 285 | h2 { 286 | margin-bottom: 5px; 287 | } 288 | p { 289 | line-height: 1.5em; 290 | } 291 | } 292 | 293 | @media(min-width: 720px) { 294 | flex-direction: row; 295 | gap: 20px; 296 | &:nth-child(even) { 297 | flex-direction: row-reverse; 298 | } 299 | 300 | .callout-text { 301 | text-align: left; 302 | align-content: center; 303 | max-width: 600px; 304 | } 305 | } 306 | } 307 | 308 | main.cart { 309 | margin: 40px auto; 310 | } 311 | 312 | #promo-banner { 313 | background-color: var(--color-primary-100); 314 | } 315 | .promo-list { 316 | margin-bottom: 20px; 317 | 318 | .product-card { 319 | align-items: center; 320 | justify-content: space-between; 321 | background-color: transparent; 322 | padding: 0; 323 | a { 324 | flex-direction: row; 325 | height: 180px; 326 | gap: 20px; 327 | min-width: 0; 328 | img { 329 | height: 100%; 330 | width: auto; 331 | } 332 | } 333 | &:hover, &:active, &:focus { 334 | transform: none; 335 | box-shadow: none; 336 | } 337 | button { 338 | margin: 0; 339 | } 340 | 341 | /* Hidden by default */ 342 | max-height: 0; 343 | opacity: 0; 344 | overflow: hidden; 345 | &.expand { 346 | max-height: 1000px; 347 | opacity: 1; 348 | /* transition: max-height 2s ease-in, 349 | opacity 1s ease-out 1s; */ 350 | 351 | } 352 | } 353 | 354 | @media (min-width: 720px) { 355 | margin-bottom: 0; 356 | .product-card { 357 | flex-direction: row; 358 | } 359 | } 360 | 361 | } 362 | 363 | 364 | /* #promo-banner { 365 | height: 260px; 366 | } */ 367 | /* 368 | #promo-banner { 369 | position: absolute; 370 | top: 60px; 371 | left: 0; 372 | right: 0; 373 | z-index: 1; 374 | } 375 | */ 376 | -------------------------------------------------------------------------------- /public/assets/css/typography.css: -------------------------------------------------------------------------------- 1 | body { 2 | font: 16px / 1.2 "Fira Sans", sans-serif; 3 | } -------------------------------------------------------------------------------- /public/assets/img/callout-1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/callout-1.png -------------------------------------------------------------------------------- /public/assets/img/callout-2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/callout-2.png -------------------------------------------------------------------------------- /public/assets/img/callout-3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/callout-3.png -------------------------------------------------------------------------------- /public/assets/img/devstickers-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/devstickers-logo.png -------------------------------------------------------------------------------- /public/assets/img/fast-sloth-sticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/fast-sloth-sticker.png -------------------------------------------------------------------------------- /public/assets/img/good-day-to-debug-sticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/good-day-to-debug-sticker.png -------------------------------------------------------------------------------- /public/assets/img/hero-desktop.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/hero-desktop.png -------------------------------------------------------------------------------- /public/assets/img/hero-mobile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/hero-mobile.png -------------------------------------------------------------------------------- /public/assets/img/js-happens-sticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/js-happens-sticker.png -------------------------------------------------------------------------------- /public/assets/img/yo-dawg-sticker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/toddhgardner/fundametals-of-web-performance/62e61a69630113d281a0406f8d6ff336ca0f3620/public/assets/img/yo-dawg-sticker.png -------------------------------------------------------------------------------- /public/assets/js/code_samples.js: -------------------------------------------------------------------------------- 1 | 2 | window.addEventListener("DOMContentLoaded", (evt) => { 3 | console.log(`DOMContentLoaded at ${evt.timeStamp} ms`); 4 | }); 5 | 6 | //> DOMContentLoaded at 1807.4000000059605 ms 7 | 8 | 9 | window.addEventListener("load", (evt) => { 10 | console.log(`Load at ${evt.timeStamp} ms`); 11 | }); 12 | 13 | //> Load at 17117 ms 14 | 15 | /** 16 | * Get the Cart Items and update the item count in the header. 17 | * jQuery Syntax like it's 2008 18 | */ 19 | $(document).ready(function () { 20 | $.ajax("/cart", { 21 | complete: function (data) { 22 | $("#cart-count").val(data.length) 23 | } 24 | }); 25 | }); 26 | 27 | 28 | 29 | 30 | 31 |
32 | 39 | 40 | 41 | 42 | console.table( 43 | [...document.images].map((img) => { 44 | const entry = performance.getEntriesByName(img.currentSrc)[0]; 45 | const bytes = (entry?.encodedBodySize * 8); 46 | const pixels = (img.width * img.height); 47 | return { src: img.currentSrc, bytes, pixels, entropy: (bytes / pixels) }; 48 | }) 49 | ) 50 | 51 | 52 | 53 | 54 | function task1() { 55 | task2(); 56 | } 57 | 58 | function task2() { 59 | task3(); 60 | } 61 | 62 | function task3() { 63 | // something 64 | } 65 | 66 | 67 | Date.now() 68 | //> 1727181644813 69 | 70 | performance.now() 71 | //> 8994.199999988079 72 | 73 | performance.timeOrigin 74 | //> 1727181678939.8 75 | 76 | performance.timeOrigin + performance.now() 77 | //> 1727181763103.9001 78 | 79 | 80 | 81 | const performanceObserver = new PerformanceObserver((list, observer) => { 82 | list.getEntries().forEach((entry) => { 83 | console.log(`Layout shifted by ${entry.value}`); 84 | }) 85 | }); 86 | performanceObserver.observe({ type: "layout-shift", buffered: true }); 87 | 88 | 89 | import { onLCP, onCLS, onINP } from "./web-vitals.mjs"; 90 | 91 | onLCP(console.log); 92 | onCLS(console.log); 93 | onINP(console.log); 94 | 95 | 96 | import { RM } from "@request-metrics/browser-agent"; 97 | 98 | RM.install({ 99 | token: "your-app-token", 100 | /* other settings */ 101 | }) 102 | 103 | const el = document.createElement("script") 104 | el.setAttribute("src", "/otherScript.js"); 105 | document.body.appendChild(el); 106 | 107 | 108 | 109 | 110 | 114 | 118 | Developer Stickers Online 122 | 123 | 124 | 125 | 126 | -------------------------------------------------------------------------------- /public/assets/js/logging.js: -------------------------------------------------------------------------------- 1 | import { onFCP, onTTFB, onLCP, onCLS, onINP } from 'https://unpkg.com/web-vitals@4/dist/web-vitals.attribution.js?module'; 2 | 3 | window.addEventListener("DOMContentLoaded", async () => { 4 | onFCP(logSummaryInfo); 5 | onTTFB(logSummaryInfo); 6 | onLCP(logSummaryInfo); 7 | onCLS(logSummaryInfo); 8 | onINP(logSummaryInfo); 9 | }); 10 | 11 | const LOG_PREFIX = '[FUNDAMENTALS OF WEB PERFORMANCE]'; 12 | const COLOR_GOOD = '#0CCE6A'; 13 | const COLOR_NEEDS_IMPROVEMENT = '#FFA400'; 14 | const COLOR_POOR = '#FF4E42'; 15 | const RATING_COLORS = { 16 | 'good': COLOR_GOOD, 17 | 'needs-improvement': COLOR_NEEDS_IMPROVEMENT, 18 | 'poor': COLOR_POOR 19 | }; 20 | 21 | const secondsFormatter = new Intl.NumberFormat(undefined, { 22 | unit: "second", 23 | style: 'unit', 24 | unitDisplay: "short", 25 | minimumFractionDigits: 3, 26 | maximumFractionDigits: 3 27 | }); 28 | 29 | const millisecondsFormatter = new Intl.NumberFormat(undefined, { 30 | unit: "millisecond", 31 | style: 'unit', 32 | unitDisplay: "short", 33 | minimumFractionDigits: 0, 34 | maximumFractionDigits: 0 35 | }); 36 | 37 | const clsFormatter = new Intl.NumberFormat(undefined, { 38 | unitDisplay: "short", 39 | minimumFractionDigits: 2, 40 | maximumFractionDigits: 2 41 | }); 42 | 43 | async function logSummaryInfo(metric) { 44 | let formattedValue; 45 | switch (metric.name) { 46 | case 'CLS': 47 | formattedValue = clsFormatter.format(metric.value); 48 | break; 49 | case 'INP': 50 | case 'Interaction': 51 | formattedValue = millisecondsFormatter.format(metric.value); 52 | break; 53 | default: 54 | formattedValue = secondsFormatter.format(metric.value / 1000); 55 | } 56 | console.groupCollapsed( 57 | `${LOG_PREFIX} ${metric.name} %c${formattedValue} (${metric.rating})`, 58 | `color: ${RATING_COLORS[metric.rating] || 'inherit'}` 59 | ); 60 | 61 | if (metric.name == 'LCP' && 62 | metric.attribution && 63 | metric.attribution.lcpEntry && 64 | metric.attribution.navigationEntry) { 65 | console.log('LCP element:', metric.attribution.lcpEntry.element); 66 | console.table([{ 67 | 'LCP sub-part': 'Time to first byte', 68 | 'Time (ms)': Math.round(metric.attribution.timeToFirstByte, 0), 69 | '% Total': Math.round((metric.attribution.timeToFirstByte / metric.value) * 100) + "%" 70 | }, { 71 | 'LCP sub-part': 'Resource load delay', 72 | 'Time (ms)': Math.round(metric.attribution.resourceLoadDelay, 0), 73 | '% Total': Math.round((metric.attribution.resourceLoadDelay / metric.value) * 100) + "%" 74 | }, { 75 | 'LCP sub-part': 'Resource load duration', 76 | 'Time (ms)': Math.round(metric.attribution.resourceLoadDuration, 0), 77 | '% Total': Math.round((metric.attribution.resourceLoadDuration / metric.value) * 100) + "%" 78 | }, { 79 | 'LCP sub-part': 'Element render delay', 80 | 'Time (ms)': Math.round(metric.attribution.elementRenderDelay, 0), 81 | '% Total': Math.round((metric.attribution.elementRenderDelay / metric.value) * 100) + "%" 82 | }]); 83 | } 84 | 85 | else if (metric.name == 'FCP' && 86 | metric.attribution && 87 | metric.attribution.fcpEntry && 88 | metric.attribution.navigationEntry) { 89 | console.log('FCP loadState:', metric.attribution.loadState); 90 | console.table([{ 91 | 'FCP sub-part': 'Time to first byte', 92 | 'Time (ms)': Math.round(metric.attribution.timeToFirstByte, 0), 93 | '% Total': Math.round((metric.attribution.timeToFirstByte / metric.value) * 100) + "%" 94 | }, { 95 | 'FCP sub-part': 'FCP render delay', 96 | 'Time (ms)': Math.round(metric.attribution.firstByteToFCP, 0), 97 | '% Total': Math.round((metric.attribution.firstByteToFCP / metric.value) * 100) + "%" 98 | }]); 99 | } 100 | 101 | else if (metric.name == 'CLS' && metric.entries.length) { 102 | for (const entry of metric.entries) { 103 | console.log('Layout shift - score: ', Math.round(entry.value * 10000) / 10000); 104 | for (const source of entry.sources) { 105 | console.log(source.node); 106 | } 107 | }; 108 | } 109 | 110 | else if ((metric.name == 'INP' || metric.name == 'Interaction') && metric.attribution) { 111 | const eventTarget = metric.attribution.interactionTargetElement; 112 | console.log('Interaction target:', eventTarget || metric.attribution.interactionTarget); 113 | console.log(`Interaction event type: %c${metric.attribution.interactionType}`, 'font-family: monospace'); 114 | 115 | // Sub parts are only available for INP events and not Interactions 116 | if (metric.name == 'INP') { 117 | console.table([{ 118 | 'Interaction sub-part': 'Input delay', 119 | 'Time (ms)': Math.round(metric.attribution.inputDelay, 0), 120 | '% Total': Math.round((metric.attribution.inputDelay / metric.value) * 100) + "%" 121 | }, 122 | { 123 | 'Interaction sub-part': 'Processing duration', 124 | 'Time (ms)': Math.round(metric.attribution.processingDuration, 0), 125 | '% Total': Math.round((metric.attribution.processingDuration / metric.value) * 100) + "%" 126 | }, 127 | { 128 | 'Interaction sub-part': 'Presentation delay', 129 | 'Time (ms)': Math.round(metric.attribution.presentationDelay, 0), 130 | '% Total': Math.round((metric.attribution.presentationDelay / metric.value) * 100) + "%" 131 | }]); 132 | } 133 | 134 | if (metric.attribution.longAnimationFrameEntries) { 135 | 136 | const allScripts = metric.attribution.longAnimationFrameEntries.map(a => a.scripts).flat(); 137 | 138 | if (allScripts.length > 0) { 139 | 140 | const sortedScripts = allScripts.sort((a, b) => b.duration - a.duration); 141 | 142 | // Pull out the pieces of interest for console table 143 | scriptData = sortedScripts.map((a) => ( 144 | { 145 | 'Duration': Math.round(a.duration, 0), 146 | 'Type': a.invokerType || null, 147 | 'Invoker': a.invoker || null, 148 | 'Function': a.sourceFunctionName || null, 149 | 'Source (links below)': a.sourceURL || null, 150 | 'Char position': a.sourceCharPosition || null 151 | } 152 | )); 153 | console.log("Long Animation Frame scripts:"); 154 | console.table(scriptData); 155 | 156 | // Get a list of scripts by sourceURL so we can log to console for 157 | // easy linked lookup. We won't include sourceCharPosition as 158 | // Devtools doesn't support linking to a character position and only 159 | // line numbers. 160 | const scriptsBySource = sortedScripts.reduce((acc, { sourceURL, duration }) => { 161 | if (sourceURL) { // Exclude empty URLs 162 | (acc[sourceURL] = acc[sourceURL] || []).push(duration); 163 | } 164 | return acc; 165 | }, {}); 166 | 167 | for (const [key, value] of Object.entries(scriptsBySource)) { 168 | console.log(`Script source link: ${key} (Duration${value.length > 1 ? 's' : ''}: ${value})`); 169 | } 170 | 171 | } 172 | } 173 | } 174 | 175 | else if (metric.name == 'TTFB' && 176 | metric.attribution && 177 | metric.attribution.navigationEntry) { 178 | console.log('TTFB navigation type:', metric.navigationType); 179 | console.table([{ 180 | 'TTFB sub-part': 'Waiting duration', 181 | 'Time (ms)': Math.round(metric.attribution.waitingDuration, 0), 182 | '% Total': Math.round((metric.attribution.waitingDuration / metric.value) * 100) + "%" 183 | }, { 184 | 'TTFB sub-part': 'Cache duration', 185 | 'Time (ms)': Math.round(metric.attribution.cacheDuration, 0), 186 | '% Total': Math.round((metric.attribution.cacheDuration / metric.value) * 100) + "%" 187 | }, { 188 | 'TTFB sub-part': 'DNS duration', 189 | 'Time (ms)': Math.round(metric.attribution.dnsDuration, 0), 190 | '% Total': Math.round((metric.attribution.dnsDuration / metric.value) * 100) + "%" 191 | }, { 192 | 'TTFB sub-part': 'Connection duration', 193 | 'Time (ms)': Math.round(metric.attribution.connectionDuration, 0), 194 | '% Total': Math.round((metric.attribution.connectionDuration / metric.value) * 100) + "%" 195 | }, { 196 | 'TTFB sub-part': 'Request duration', 197 | 'Time (ms)': Math.round(metric.attribution.requestDuration, 0), 198 | '% Total': Math.round((metric.attribution.requestDuration / metric.value) * 100) + "%" 199 | }]); 200 | } 201 | 202 | console.log(metric); 203 | console.groupEnd(); 204 | } -------------------------------------------------------------------------------- /public/assets/js/promo.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Promo Banner 3 | * Fundamentals of Web Performance 4 | * 5 | * Example add-on script that gets the promoted products for the store and 6 | * renders a promotional banner. This demonstrates: 7 | * 8 | * 1. Unnecessary render delay 9 | * 2. Downloading resources that are not shown 10 | * 3. Shifting layouts 11 | */ 12 | window.addEventListener("DOMContentLoaded", async () => { 13 | 14 | const productsResp = await fetch(`${API_BASE_URL}/api/products`); 15 | const products = await productsResp.json(); 16 | 17 | const el = document.getElementById("promo-banner"); 18 | 19 | let innerHTML = "
" 20 | products.forEach((product) => { 21 | innerHTML += ` 22 | `; 34 | }); 35 | innerHTML += "
" 36 | innerHTML += "
"; 37 | el.innerHTML = innerHTML; 38 | 39 | // expand the promoted product 40 | window.showPromo = function () { 41 | setTimeout(() => { 42 | el.querySelector(".product-card.promo").classList.add("expand"); 43 | }, 2000); 44 | } 45 | 46 | }); 47 | -------------------------------------------------------------------------------- /public/assets/js/scripts.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Frontend Script 3 | * Fundamentals of Web Performance 4 | * 5 | * These are the scripts for the frontend website. It contains options to call 6 | * both the RESTful and Bespoke APIs. 7 | * 8 | * It also contains some performance problems that we will explore and fix: 9 | * 1. 10 | */ 11 | window.addEventListener("DOMContentLoaded", async () => { 12 | 13 | const { user, cart, products } = await getDataRESTfully(); 14 | 15 | /** 16 | * Initial update of the document with information from the API 17 | */ 18 | renderCartCount(cart); 19 | renderCartContents(cart, products); 20 | 21 | /** 22 | * Attach Cart Click Handlers 23 | * We attache them to body because the buttons may be re-rendered during the 24 | * lifetime of the page. 25 | */ 26 | document.body.addEventListener("click", async (evt) => { 27 | const el = evt.target; 28 | if (el.matches("button.add-to-cart")) { 29 | const productId = parseInt(el.getAttribute("data-product-id"), 10); 30 | updateAnalytics(); 31 | await addToCart(user, productId); 32 | 33 | /** 34 | * TODO Performance Opportunity 35 | * 36 | * We do a lot of expensive work on the main thread in this handler, and 37 | * we don't provide much user feedback. This makes interactivity feel 38 | * sluggish. 39 | */ 40 | // const productId = parseInt(el.getAttribute("data-product-id"), 10); 41 | // requestAnimationFrame(() => { 42 | // el.textContent = "Added!"; 43 | // el.setAttribute("disabled", "disabled"); 44 | // }); 45 | // setTimeout(() => { 46 | // updateAnalytics(); 47 | // }); 48 | // await addToCart(user, productId); 49 | // setTimeout(() => { 50 | // el.textContent = "Add to Cart"; 51 | // el.removeAttribute("disabled"); 52 | // }, 1500); 53 | } 54 | else if (el.matches("button.remove-from-cart")) { 55 | const cartItemId = el.getAttribute("data-cart-item-id"); 56 | await removeFromCart(user, cartItemId, products); 57 | } 58 | else if (el.matches("button.clear-cart")) { 59 | await clearCart(user); 60 | } 61 | }); 62 | 63 | }); 64 | 65 | async function getCart(userId) { 66 | let resp = await fetch(`${API_BASE_URL}/api/users/${userId}/cart`) 67 | return await resp.json(); 68 | } 69 | 70 | async function getProducts(userId) { 71 | let resp = await fetch(`${API_BASE_URL}/api/products?userId=${userId}`); 72 | return await resp.json(); 73 | } 74 | 75 | /** 76 | * RESTful pattern of getting data 77 | * Sequential calls for single entities. 78 | */ 79 | async function getDataRESTfully() { 80 | 81 | async function getUser() { 82 | let userId = getLocalUserId(); 83 | if (!userId) { 84 | return null; 85 | } 86 | let userResp = await fetch(`${API_BASE_URL}/api/users/${userId}`); 87 | if (userResp.status !== 200) { 88 | // Our local user does not exist, clear it out. 89 | clearLocalUserId(); 90 | return null; 91 | } 92 | let user = await userResp.json(); 93 | saveLocalUserId(user.id); 94 | return user; 95 | } 96 | 97 | async function createUser(name) { 98 | let createUserResp = await fetch(`${API_BASE_URL}/api/users`, { 99 | method: "POST", 100 | headers: { "Content-Type": "application/json" }, 101 | body: JSON.stringify({ name }) 102 | }); 103 | 104 | const location = createUserResp.headers.get("Location"); 105 | if (!location || createUserResp.status !== 201) { 106 | throw new Error("Non RESTFUL Response"); 107 | } 108 | 109 | let userResp = await fetch(location); 110 | let user = await userResp.json(); 111 | saveLocalUserId(user.id); 112 | return user; 113 | } 114 | 115 | let user = await getUser() ?? await createUser("unknown"); 116 | let cartTask = getCart(user.id); 117 | let productsTask = getProducts(user.id); 118 | 119 | return { 120 | user, 121 | cart: await cartTask, 122 | products: await productsTask 123 | }; 124 | } 125 | 126 | /** 127 | * addToCart 128 | * Adds an item to the user's cart, then updates the UI. 129 | */ 130 | async function addToCart(user, productId) { 131 | const userId = user.id; 132 | const resp = await fetch(`${API_BASE_URL}/api/users/${userId}/cart`, { 133 | method: "POST", 134 | headers: { "Content-Type": "application/json" }, 135 | body: JSON.stringify({ productId, userId }) 136 | }); 137 | const cart = await resp.json(); 138 | renderCartCount(cart); 139 | } 140 | 141 | /** 142 | * removeFromCart 143 | * Removes a cartItem from the user's cart, then updates the UI. 144 | */ 145 | async function removeFromCart(user, cartItemId, products) { 146 | const resp = await fetch(`${API_BASE_URL}/api/users/${user.id}/cart/${cartItemId}`, { 147 | method: "DELETE" 148 | }); 149 | const cart = await resp.json(); 150 | renderCartCount(cart); 151 | renderCartContents(cart, products); 152 | } 153 | 154 | /** 155 | * clearCart 156 | * Clears the entire user cart and updates the UI 157 | */ 158 | async function clearCart(user) { 159 | const userId = user.id 160 | const resp = await fetch(`${API_BASE_URL}/api/users/${userId}/cart`, { 161 | method: "DELETE" 162 | }); 163 | const cart = await resp.json(); 164 | renderCartCount(cart); 165 | renderCartContents(cart); 166 | } 167 | 168 | /** 169 | * renderCartContents 170 | * Client-side Rendering of the contents of the shopping cart from the API. Only 171 | * works with the presence of a #cart-items element. 172 | */ 173 | function renderCartContents(cart, products) { 174 | const el = document.getElementById("cart-items"); 175 | if (!el) { return; } 176 | 177 | el.innerHTML = ""; 178 | cart.forEach((cartItem) => { 179 | const product = products.find((p) => p.id === cartItem.productId); 180 | el.innerHTML = el.innerHTML + ` 181 |
  • 182 | 183 | ${product.name} 184 |

    ${product.name}

    185 |
    186 |
    187 | 188 |
    189 |
  • `; 190 | }) 191 | } 192 | 193 | /** 194 | * renderCartCount 195 | * Client-side rendering of the number of items in the shopping cart in the header. 196 | */ 197 | function renderCartCount(cart) { 198 | document.getElementById('cart-count').textContent = cart.length; 199 | } 200 | 201 | /** 202 | * updateAnalytics 203 | * Dummy function that simulates doing a lot of expensive work. 204 | */ 205 | function updateAnalytics() { 206 | performance.mark("analytics_start"); 207 | const phantomEl = document.createElement("div"); 208 | for (var i = 0; i <= 200_000; i++) { 209 | let child = document.createElement("div"); 210 | child.textContent = i; 211 | phantomEl.appendChild(child); 212 | } 213 | performance.mark("analytics_end"); 214 | performance.measure("analytics", "analytics_start", "analytics_end") 215 | } 216 | 217 | /** 218 | * Utility helper functions 219 | */ 220 | function getLocalUserId() { 221 | return localStorage.getItem("userId"); 222 | } 223 | function saveLocalUserId(userId) { 224 | localStorage.setItem("userId", userId); 225 | } 226 | function clearLocalUserId() { 227 | localStorage.removeItem("userId"); 228 | } 229 | -------------------------------------------------------------------------------- /public/cart.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Your Cart - Developer Stickers Online 8 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 | 32 |
    33 | 34 |
    35 |
    36 |

    Your Cart

    37 | 38 |
    39 | 40 | 43 | 44 |
    45 | 46 |
    47 |
    48 | 49 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Developer Stickers Online 8 | 9 | 10 | 11 | 12 | 13 | 16 | 17 | 18 | 22 | 23 | 24 | 25 | 26 | 27 |
    28 | 42 |
    43 | 44 |
    45 | 46 |
    47 | 48 |
    49 |
    50 | Developer Stickers 51 | Developer Stickers 52 |
    53 | 54 | 55 |
    56 |
    57 |

    58 | Level up your Sticker Game 59 |

    60 |

    61 | Grab our exclusive developer stickers and show off your love/hate for JavaScript 62 | and the web. 63 |

    64 |
    65 |
    66 |
    67 | 68 | 119 | 120 |
    121 |
    122 | 123 | Express Your Developer Personality 124 | 125 |
    126 |

    Express Your Developer Personality

    127 |

    128 | Why settle for a plain device when you can show off your unique developer personality? Our stickers let you 129 | add humor, creativity, and flair to your gear—whether it's your laptop, phone, or desk. Each sticker is 130 | designed with developers in mind, so you can make coding cool while staying true to your style. 131 |

    132 |
    133 |
    134 |
    135 | 136 | High-Quality Stickers Built to Last 137 | 138 |
    139 |

    High-Quality Stickers Built to Last

    140 |

    141 | Crafted with high-quality materials, our stickers are made to last. They’re resistant to wear and tear, so 142 | whether you're at the office or out at a hackathon, you can trust your stickers to stay as bold and fun as 143 | the day you got them. Slap them on your laptop, phone, or water bottle—they’ll keep sticking with you 144 | through all your coding adventures. 145 |

    146 |
    147 |
    148 |
    149 | 150 | Perfect for Gifting 151 | 152 |
    153 |

    Perfect for Gifting

    154 |

    155 | Looking for the perfect gift for your developer friends or colleagues? Our stickers make an awesome, 156 | personalized gift that every coder will appreciate. Whether it’s a quirky JavaScript pun or a debugging 157 | sloth, these stickers are sure to bring a smile and make their workstations pop. 158 |

    159 |
    160 |
    161 |
    162 | 163 |
    164 | 165 | 177 | 178 | 179 | 180 | 181 | 189 | 190 | 557 | 558 | -------------------------------------------------------------------------------- /public/products/fast-sloth-sticker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Fast Sloth Sticker - Developer Stickers Online 8 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 | 32 |
    33 | 34 |
    35 |
    36 | 37 | Fast Sloth Sticker 38 | 39 | 40 |
    41 |

    Fast Sloth Sticker

    42 |

    43 | Get your hands on this awesome Request Metrics sloth sticker, featuring our chill mascot riding a 44 | rocket—perfect for your laptop or desk! 45 |

    46 | 47 |
    48 |
    49 |
    50 | 51 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /public/products/good-day-to-debug-sticker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Good Day to Debug Sticker - Developer Stickers Online 8 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 | 32 |
    33 | 34 |
    35 |
    36 | 37 | Good Day to Debug Sticker 38 | 39 | 40 |
    41 |

    Good Day to Debug Sticker

    42 |

    43 | Channel your inner warrior with this epic Request Metrics sticker. Debug with honor. 44 |

    45 | 46 |
    47 |
    48 |
    49 | 50 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /public/products/js-happens-sticker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | JavaScript Happens Sticker - Developer Stickers Online 8 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 | 32 |
    33 | 34 |
    35 |
    36 | 37 | JavaScript Happens Sticker 38 | 39 | 40 |
    41 |

    JavaScript Happens Sticker

    42 |

    43 | Embrace the inevitable. You can't stop it. JavaScript will happen. 44 |

    45 | 46 |
    47 |
    48 |
    49 | 50 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /public/products/yo-dawg-sticker.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Yo Dawg Sticker - Developer Stickers Online 8 | 12 | 13 | 14 | 15 | 16 | 17 |
    18 | 32 |
    33 | 34 |
    35 |
    36 | 37 | Yo Dawg Sticker 38 | 39 | 40 |
    41 |

    Yo Dawg Sticker

    42 |

    43 | Develop JavaScript like Xzibit. You always need more JavaScript. 44 |

    45 | 46 |
    47 |
    48 |
    49 | 50 | 62 | 63 | 64 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Example Application Server 3 | * Fundamentals of Web Performance 4 | * 5 | * Simple static content server to illustrate the performance impact of HTTP 6 | * protocol, headers, delays, and compression on the overall user experience of 7 | * a website. 8 | */ 9 | const express = require("express"); 10 | const { resolve } = require("path"); 11 | const compression = require("http-compression"); 12 | 13 | const api = require("./routes/api"); 14 | const performanceConfig = require("../performance-config"); 15 | 16 | const app = express(); 17 | 18 | /** 19 | * Simulating real-world delays for server processing duration and network 20 | * latency. 21 | */ 22 | app.use((_req, _res, next) => { 23 | setTimeout(next, performanceConfig.serverDuration); 24 | }); 25 | 26 | /** 27 | * Compression of response bodies. 28 | * @see https://www.npmjs.com/package/http-compression 29 | */ 30 | app.use(compression({ 31 | threshold: 1, // so we can always see it for testing. 32 | gzip: performanceConfig.enableGzipCompression, 33 | brotli: performanceConfig.enableBrotliCompression 34 | })); 35 | 36 | /** 37 | * Host the API Routes 38 | */ 39 | app.use("/api", api); 40 | 41 | /** 42 | * Host static assets in the ./public directory 43 | * @see https://expressjs.com/en/5x/api.html#express.static 44 | */ 45 | app.use(express.static(resolve(__dirname, "..", "public"), { 46 | extensions: ["html"], 47 | etag: performanceConfig.enable304CachingHeaders, 48 | lastModified: performanceConfig.enable304CachingHeaders, 49 | cacheControl: performanceConfig.enableBrowserCache, 50 | maxAge: performanceConfig.enableBrowserCache ? 7200000 : 0, 51 | })); 52 | 53 | module.exports = app; 54 | -------------------------------------------------------------------------------- /src/data/cartQuery.js: -------------------------------------------------------------------------------- 1 | const database = require('./database'); 2 | 3 | /** 4 | * Database SQL Statements 5 | */ 6 | const TABLE_NAME = "cartItems"; 7 | 8 | const selectByUserStatement = database.prepare(` 9 | SELECT id, t, userId, productId 10 | FROM ${TABLE_NAME} 11 | WHERE userId = @userId`); 12 | 13 | const insertStatement = database.prepare(` 14 | INSERT 15 | INTO ${TABLE_NAME} (userId, productId) VALUES (@userId, @productId)`); 16 | 17 | const deleteStatement = database.prepare(` 18 | DELETE FROM ${TABLE_NAME} 19 | WHERE id = @cartItemId 20 | AND userId = @userId`); 21 | 22 | const clearStatement = database.prepare(` 23 | DELETE FROM ${TABLE_NAME} 24 | WHERE userId = @userId`); 25 | 26 | module.exports = { 27 | getByUser: async ({ userId }) => await selectByUserStatement.all({ userId }), 28 | insert: async ({ userId, productId }) => await insertStatement.run({ userId, productId }), 29 | delete: async ({ userId, cartItemId }) => await deleteStatement.run({ userId, cartItemId }), 30 | deleteAll: async ({ userId }) => await clearStatement.run({ userId }) 31 | } 32 | -------------------------------------------------------------------------------- /src/data/database.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Example Database 3 | * Fundamentals of Web Performance 4 | * 5 | * Simplified example database for an ecommerce store using SQLite3. We keep it 6 | * in memory so it resets every time. 7 | */ 8 | const Sqlite3 = require("better-sqlite3"); 9 | const database = new Sqlite3(":memory:"); 10 | 11 | /** 12 | * Setup the database from schema. 13 | */ 14 | const { readFileSync } = require("node:fs"); 15 | const { resolve } = require("node:path"); 16 | const schema = readFileSync(resolve(__dirname, "schema.sql"), "utf8"); 17 | database.exec(schema); 18 | 19 | module.exports = database; -------------------------------------------------------------------------------- /src/data/productsQuery.js: -------------------------------------------------------------------------------- 1 | const database = require('./database'); 2 | 3 | /** 4 | * Database SQL Statements 5 | */ 6 | const TABLE_NAME = "products"; 7 | 8 | const getAllStatement = database.prepare(` 9 | SELECT id, slug, name, description, imagePath 10 | FROM ${TABLE_NAME}`); 11 | 12 | module.exports = { 13 | getAll: async () => await getAllStatement.all() 14 | }; 15 | 16 | -------------------------------------------------------------------------------- /src/data/schema.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- Database Schema 3 | -- Fundamentals of Web Performance 4 | -- 5 | -- Simplified Database design for an online store with products, users, and carts. 6 | -- 7 | 8 | PRAGMA foreign_keys = ON; 9 | 10 | CREATE TABLE IF NOT EXISTS 11 | products ( 12 | id INTEGER PRIMARY KEY AUTOINCREMENT, 13 | t TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 14 | slug TEXT NOT NULL, 15 | name TEXT NOT NULL, 16 | description TEXT, 17 | imagePath TEXT 18 | ); 19 | 20 | CREATE TABLE IF NOT EXISTS 21 | users ( 22 | id INTEGER PRIMARY KEY AUTOINCREMENT, 23 | t TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 24 | name TEXT 25 | ); 26 | 27 | CREATE TABLE IF NOT EXISTS 28 | cartItems ( 29 | id INTEGER PRIMARY KEY AUTOINCREMENT, 30 | t TIMESTAMP DEFAULT CURRENT_TIMESTAMP, 31 | userId INTEGER NOT NULL, 32 | productId INTEGER NOT NULL, 33 | FOREIGN KEY (userId) REFERENCES users (id) 34 | FOREIGN KEY (productId) REFERENCES products (id) 35 | ); 36 | 37 | -- Seed initial data into product table 38 | INSERT INTO products (slug, name, description, imagePath) 39 | VALUES 40 | ( 41 | 'fast-sloth-sticker', 42 | 'Fast Sloth Sticker', 43 | 'Get your hands on this awesome Request Metrics sloth sticker, featuring our chill mascot riding a rocket—perfect for your laptop or desk!', 44 | '/assets/img/fast-sloth-sticker.png' 45 | ), 46 | ( 47 | 'good-day-to-debug-sticker', 48 | 'Good Day to Debug Sticker', 49 | 'Channel your inner warrior with this epic Request Metrics sticker. Debug with honor.', 50 | '/assets/img/good-day-to-debug-sticker.png' 51 | ), 52 | ( 53 | 'js-happens-sticker', 54 | 'JavaScript Happens Sticker', 55 | 'Embrace the inevitable. You can't stop it. JavaScript will happen.', 56 | '/assets/img/js-happens-sticker.png' 57 | ), 58 | ( 59 | 'yo-dawg-sticker', 60 | 'Yo Dawg Sticker', 61 | 'Develop JavaScript like Xzibit. You always need more JavaScript.', 62 | '/assets/img/yo-dawg-sticker.png' 63 | ); 64 | 65 | 66 | -------------------------------------------------------------------------------- /src/data/usersQuery.js: -------------------------------------------------------------------------------- 1 | const database = require('./database'); 2 | 3 | /** 4 | * Database SQL Statements 5 | */ 6 | const TABLE_NAME = "users"; 7 | 8 | const selectByIdStatement = database.prepare(` 9 | SELECT id, t, name 10 | FROM ${TABLE_NAME} 11 | WHERE id = @userId`); 12 | 13 | const insertStatement = database.prepare(` 14 | INSERT INTO ${TABLE_NAME} (name) VALUES (@name)`); 15 | 16 | module.exports = { 17 | create: async ({ name }) => await insertStatement.run({ name }), 18 | getById: async ({ userId }) => await selectByIdStatement.get({ userId }) 19 | }; 20 | -------------------------------------------------------------------------------- /src/lib/getRandom.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | 3 | /** 4 | * Generate a random number between the minimum and maximum inclusive. 5 | * @param {number} min 6 | * @param {number} max 7 | * @see https://stackoverflow.com/questions/4959975/generate-random-number-between-two-numbers-in-javascript 8 | */ 9 | getRandom: (min, max) => { 10 | return Math.floor(Math.random() * (max - min + 1) + min); 11 | } 12 | 13 | } -------------------------------------------------------------------------------- /src/routes/api.js: -------------------------------------------------------------------------------- 1 | /** 2 | * API Routes 3 | * Fundamentals of Web Performance 4 | * 5 | * This is an example API for interacting with the Store Data. 6 | */ 7 | 8 | const { Router } = require("express"); 9 | const bodyParser = require("body-parser"); 10 | 11 | const { getRandom } = require("../lib/getRandom"); 12 | const cartQuery = require("../data/cartQuery"); 13 | const usersQuery = require("../data/usersQuery"); 14 | const productsQuery = require("../data/productsQuery"); 15 | 16 | const jsonParser = bodyParser.json(); 17 | const apiRouter = new Router(); 18 | 19 | /** 20 | * Product API 21 | * RESTful API Endpoints 22 | */ 23 | apiRouter.get("/products", async (req, res, next) => { 24 | const products = await productsQuery.getAll(); 25 | products[getRandom(0, (products.length - 1))].isPromo = true; 26 | res.json(products); 27 | next(); 28 | }) 29 | 30 | /** 31 | * User API 32 | * RESTful API Endpoints 33 | */ 34 | apiRouter.get("/users/:userId", async (req, res, next) => { 35 | const { userId } = req.params; 36 | const user = await usersQuery.getById({ userId }); 37 | if (!user) { 38 | res.status(404).json({ error: "User not found" }); 39 | return next(); 40 | } 41 | res.json(user); 42 | next(); 43 | }); 44 | apiRouter.post("/users", jsonParser, async (req, res, next) => { 45 | const { name } = req.body; 46 | const result = await usersQuery.create({ name }); 47 | res.set("Location", `${req.get("origin")}${req.originalUrl}/${result.lastInsertRowid}`); 48 | res.sendStatus(201); 49 | next(); 50 | }); 51 | 52 | /** 53 | * Cart API 54 | * RESTful API Endpoints 55 | */ 56 | apiRouter.get("/users/:userId/cart", async (req, res, next) => { 57 | const { userId } = req.params; 58 | const user = await usersQuery.getById({ userId }); 59 | if (!user) { 60 | res.status(404).json({ error: "User not found" }); 61 | return next(); 62 | } 63 | const data = await cartQuery.getByUser({ userId }); 64 | res.json(data); 65 | next(); 66 | }); 67 | apiRouter.post("/users/:userId/cart", jsonParser, async (req, res, next) => { 68 | const { userId } = req.params; 69 | const { productId } = req.body; 70 | await cartQuery.insert({ userId, productId }); 71 | res.status(201); 72 | const data = await cartQuery.getByUser({ userId }); 73 | res.json(data); 74 | next(); 75 | }); 76 | apiRouter.delete("/users/:userId/cart", async (req, res, next) => { 77 | const { userId } = req.params; 78 | await cartQuery.deleteAll({ userId }); 79 | res.status(200); 80 | const data = await cartQuery.getByUser({ userId }); 81 | res.json(data); 82 | next(); 83 | }); 84 | apiRouter.delete("/users/:userId/cart/:cartItemId", async (req, res, next) => { 85 | const { userId, cartItemId } = req.params; 86 | await cartQuery.delete({ userId, cartItemId }); 87 | res.status(200); 88 | const data = await cartQuery.getByUser({ userId }); 89 | res.json(data); 90 | next(); 91 | }); 92 | 93 | module.exports = apiRouter; 94 | -------------------------------------------------------------------------------- /tools/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | # Deployment Tool 3 | # Fundamentals of Web Performance 4 | # 5 | # Bash helper script to send our current changes out to the example server 6 | # during the workshop. 7 | # 8 | cd "$(dirname "$0")" 9 | cd .. 10 | 11 | # Load environment variables 12 | . .env 13 | 14 | green='\033[0;32m' 15 | clear='\033[0m' 16 | bold=$(tput bold) 17 | normal=$(tput sgr0) 18 | 19 | # Create a Tarball of the current local committed source 20 | rm -rf ./dist 21 | mkdir ./dist 22 | git archive --format=tar.gz -o ./dist/deploy.tar.gz HEAD 23 | printf "${green}${bold}Package Created${normal}${clear} /dist/deploy.tar.gz\n" 24 | 25 | # Upload the tarball to the host server 26 | rsync ./dist/deploy.tar.gz $HOST:/srv/node/devstickers/temp/ 27 | printf "${green}${bold}Uploaded to Host${normal}${clear}\n" 28 | 29 | # Swap the new content 30 | ssh $HOST > /dev/null << EOF 31 | tar xf /srv/node/devstickers/temp/deploy.tar.gz -C /srv/node/devstickers/app 32 | cd /srv/node/devstickers/app 33 | npm install --omit=dev 34 | rm -rf /srv/node/devstickers/temp 35 | EOF 36 | printf "${green}${bold}Package Deployed${normal}${clear}\n" 37 | 38 | # Purge CDN Cache 39 | curl --request POST \ 40 | --url "https://api.bunny.net/pullzone/${BUNNY_PULL_ZONE}/purgeCache" \ 41 | --header "AccessKey: ${BUNNY_ACCESS_KEY}" \ 42 | --header "Content-Type: application/json" 43 | printf "${green}${bold}CDN Cache Purged${normal}${clear}\n\n" -------------------------------------------------------------------------------- /tools/imagePngOptimize.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * PNG Optimizer 3 | * Fundamentals of Web Performance 4 | * 5 | * Build tool to optimize the PNG images and place them in the `min` directory. 6 | * 7 | * If images have already been resized, the responsive images will be optimized 8 | * as well. 9 | * 10 | * @see https://www.npmjs.com/package/imagemin 11 | */ 12 | 13 | import imagemin from 'imagemin'; 14 | import imageminPngquant from 'imagemin-pngquant'; 15 | 16 | console.log("Optimizing PNG Images"); 17 | 18 | await imagemin(['public/assets/img/**/*.png'], { 19 | destination: 'public/assets/img/min', 20 | plugins: [ 21 | imageminPngquant({ 22 | quality: [0.6, 0.8] 23 | }) 24 | ] 25 | }); 26 | -------------------------------------------------------------------------------- /tools/imagePngResizer.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * PNG Resizer 3 | * Fundamentals of Web Performance 4 | * 5 | * Build tool to generate responsive sizes for all the images on the size and 6 | * put in the `r` directory. 7 | * @see https://jimp-dev.github.io/jimp/ 8 | */ 9 | 10 | import { parse } from 'node:path'; 11 | import { mkdir } from 'node:fs/promises'; 12 | import { Jimp } from "jimp"; 13 | import { glob } from "glob"; 14 | 15 | const filePaths = await glob('public/assets/img/*.png') 16 | const widths = [360, 720, 1024, 1400, 2800]; 17 | 18 | console.log("Generating Responsive Images"); 19 | 20 | await mkdir(`public/assets/img/r`, { recursive: true }); 21 | 22 | filePaths.forEach(async (path) => { 23 | widths.forEach(async (width) => { 24 | const sourcePath = parse(path); 25 | const file = await Jimp.read(path); 26 | const resizedFile = await file.resize({ w: width }); 27 | await resizedFile.write(`public/assets/img/r/${sourcePath.name}-${width}${sourcePath.ext}`); 28 | }); 29 | }); 30 | 31 | -------------------------------------------------------------------------------- /tools/imagePngToWebP.mjs: -------------------------------------------------------------------------------- 1 | /** 2 | * WebP Converter 3 | * Fundamentals of Web Performance 4 | * 5 | * Build tool to optimize convert the optimized PNGs in the `min` directory 6 | * into WebP files, placed in the `webp` directory. 7 | * 8 | * @see https://www.npmjs.com/package/imagemin 9 | */ 10 | 11 | import imagemin from 'imagemin'; 12 | import imageminWebp from 'imagemin-webp'; 13 | 14 | console.log("Converting to WebP Images",); 15 | 16 | await imagemin(['public/assets/img/min/**/*.png', 'public/assets/img/*.png'], { 17 | destination: "public/assets/img/webp", 18 | plugins: [ 19 | imageminWebp({ quality: 50 }) 20 | ] 21 | }); 22 | --------------------------------------------------------------------------------