/progressive-web-apps-2021

The course Progressive Web Apps is about learning to build server side rendered applications. Progressive Web Apps is part of the half year minor programme about Web Design and Development in Amsterdam. Bachelor Communication and Multimedia Design, Amsterdam University of Applied Science.

Primary LanguageJavaScriptMIT LicenseMIT

Weather app

This application shows you the weather for all over the world! With the interactive map you can search all over the world.

Screenshots

Live link to the application: progressive-weatherapp.herokuapp.com

Course description

For this course we learn to build a server side rendered application. Implement a service worker with some functionalities. In the end the application will be a real and optimized in performance Progressive Web App!

Table of Contents

  1. Features
  2. Install
  3. Building
  4. Templating engine
  5. Rendering
  6. Static building

Features

  • Interactive map
  • Weather based on searched city
  • Weather based on your own location
  • Clickable pop-up forwarding to a detail page
  • Detail page with a "last updated on" reminder
  • Detail page with the temperature and weather description
  • Background visuals matching the weather and local time
  • Pre built files, so server can serve files way faster
  • Compression, compress middleware so files are smaller and faster loading time
  • Minify and bundle CSS and JS files to optimize performance
  • Offline caching, so you can see weather offline
  • Weather forecast, for upcoming days

Install

$ git clone https://github.com/Jelmerovereem/progressive-web-apps-2021
$ cd progressive-web-apps-2021/

Once you're in the directory, install the required node modules:

$ npm install

Then build the static dist folder

$ npm run build

Fill in your .env file with your API keys. (See .env.example)

apikey={YOUR_KEY}

Finally, start the server:

$ npm run start-server

Or build & start the server with:

$ npm run bas

Build

Server

For building and running the server I use the express framework.
This minimalist web framework is very useful for setting up a server.
You first require express:

const express = require("express");

After that you init your app:

const app = express();

Config your express-app:

app.use(express.static("static")); // indicate which folder will be the public/static folder
app.set("view engine", "ejs"||"pug") // indicate which templating engine you're using

// at the bottom of your file
const port = 7000;
app.listen(port, () => console.log(`Server is running on port ${port}`)); // your url will be localhost:7000

Templating engine

I use pug as my templating engine for this project. I've already worked with ejs before, so it'll be more of a challenge if I use pug this time.

To let express know what template engine I'm using:

app.set("view engine", "pug");

Rendering

Now we can render our files:

app.get("/", renderHome); // if app receives a get request on "/", call function renderHome

function renderHome(req, res) {
	res.render("home"); // render the home file inside your "views" folder
}

Passing data with rendering:

function renderHome(req, res) {
	const title = "Weather app";
	res.render("home", {
		pageTitle: title // pass data as an object
	});
}

Inside your templating engine file:

html(lang="en")
	head
		title #{pageTitle}

Static building

I followed the talk from Declan for pre-building your website. I followed his steps and now the home page is pre-build inside the dist/ folder. The templating engine is rendered, the static assets are copy pasted and the CSS & JavaScript files are also pre-built.

Remove old files

import rimraf from "rimraf";

rimraf("./dist/*", () => {console.log("cleared dist")});

Building HTML / render template file

import pug from "pug";
import fs from "file-system";

function createHtml() {
	const title = "Weather app";
	const data = {
		pageTitle: title
	}

	const html = pug.renderFile("./views/home.pug", data); // compile and render file

	fs.writeFile("./dist/index.html", html, (err) => { // write the html to the dist folder
		if (err) console.log(err); // show error if present
	})
}

Copy paste static assets

import gulp from "gulp";

gulp.src([ // copy paste manifest and serviceworker
	"./static/manifest.json",
	"./static/service-worker.js"
	]).pipe(gulp.dest("./dist/"))
gulp.src("./static/assets/**/*.*").pipe(gulp.dest("./dist/assets/")) // copy paste all other static assets

Build CSS

import gulp from "gulp";
import cleanCss from "gulp-clean-css";
import concat from "gulp-concat";

gulp.src([
	"./static/styles/*.css"
	])
.pipe(cleanCss()) // minifies the CSS files
.pipe(concat("main.css")) // concat all css files to one file
.pipe(gulp.dest("./dist/styles/"))

Build js

import gulp from "gulp";
import babel from "gulp-babel";
import uglify from "gulp-uglify";
import concat from "gulp-concat";
import rollup from "gulp-rollup";

gulp.src([
	"./static/scripts/main.js"])
	.pipe(rollup({ // bundle the ES6 module files
		input: "./static/scripts/main.js",
		allowRealFiles: true,
		format: "esm"
	}))
	.pipe(babel()) // create backwards compatible JavaScript. Mostly syntax
	.pipe(uglify()) // minify javascript
	.pipe(concat("main.js")) // concact all JavaScript files to one file
	.pipe(gulp.dest("./dist/scripts/"))

Manifest and Service Worker

A (good) Progressive Web app needs to work offline and be able to install as an (desktop)app! For this to work your can implement a manifest and service worker.

Manifest

The manifest is a JSON file where you give instructions on how to display the app when someone installs it as an app. Here is mine:

{
	"name": "Weather app",
	"short_name": "Weather app",
	"description": "Get the weather details from all over the world with a interactive map.",
	"theme_color": "#fc043c",
	"background_color": "#fc043c",
	"display": "standalone",
	"Scope": "/",
	"start_url": "/",
	"icons": [
		{
			"src": "assets/favicon.png",
			"sizes": "72x72",
			"type": "image/png"
		}]
}

Service worker

A service worker manages the users network requests. With a service worker you can intercept / modify network traffic, cache files and resources, add push notifications, etc.

You can call/intercept different events from the service worker to get your application to work offline:

  • Install
  • Activate
  • Fetch

How I used the service worker:
Init

const CACHE_VERSION = "v1"; // init a version for versioning (not yet in use)
const CORE_ASSETS = [ // init assets that need to be cached
	"/offline",
	"/assets/favicon.png",
	"/styles/main.css",
	"manifest.json",
	"/assets/sun.png",
	"/assets/moon.png",
	"/assets/back.svg"
]
const EXCLUDE_FILES = [ // init assets that need to be excluded from caching
	"/" // home page
]

Install

self.addEventListener("install", (event) => { // install the service worker in the browser
	console.log("installing service worker");
	/* if service worker isn't installed yet */
	event.waitUntil(
			caches.open(CACHE_VERSION) // open given version
				.then(cache => {
					cache.addAll(CORE_ASSETS).then(() => self.skipWaiting()); // cache all the given assets
				})
		)
})

Activate

self.addEventListener("activate", (event) => {
	console.log("activating service worker")
	event.waitUntil(clients.claim()); // check all tabs and handle all requests
	caches.keys().then((keyList) => { // get all cache storages
		return Promise.all(
			keyList.map((cache) => {
				if (cache.includes(CACHE_VERSION) && cache !== CACHE_VERSION) { // if cache is not current version
					return caches.delete(cache) // delete cache
				}
			}))
	})
})

Fetch

self.addEventListener("fetch", async (event) => {
	if (event.request.method === "GET" && CORE_ASSETS.includes(getPathName(event.request.url))) { // if a request matches a core asset
		event.respondWith(
			caches.open(CACHE_VERSION).then(cache => cache.match(event.request.url)) // check if cache already exists
		)
	} else if (isHtmlGetRequest(event.request)) { // if it isn't a core asset but it is a html request
		event.respondWith(
			caches.open("html-runtime-cache") // open the html-runtime-cache
				.then(cache => cache.match(event.request)) // check if cache already exists
				.then((response) => {
					if (response) {
						return response;
					} else { // if cache does not already exists, cache the request and send msg to client
						if (getPathName(event.request.url) !== "/") {
							postMessageToClient(event, getPathName(event.request.url))
						}
						return fetchAndCache(event.request, "html-runtime-cache");
					}
				})
				.catch(() => {
					return caches.open(CACHE_VERSION).then(cache => cache.match("/offline")) // if request is not cached, view offline page
				})
		)
	}
})

Helpers

function getPathName(requestUrl) {
	const url = new URL(requestUrl);
	return url.pathname;
}

function fetchAndCache(request, cachename) {
	return fetch(request)
		.then(response => {
			if (getPathName(request.url) !== "/") {
				const clone = response.clone();
				caches.open(cachename)
					.then(cache => cache.put(request, clone)) // cache request
					return response
			} else {
				return response
			}
		})
}

function isHtmlGetRequest(request) {
	return request.method === "GET" && (request.headers.get("accept") !== null && request.headers.get("accept").indexOf("text/html") > -1)
}

async function postMessageToClient(event, url) { // send url for localstorage
	const client = await clients.get(event.resultingClientId);
	if (client) {
		client.postMessage({
			msg: "localStorage",
			url: url
		})
	} else {
		console.error("Client is undefined");
	}
}

Optimizing

I've used multiple optimizing methods, like compression and caching.

Performance terms

Critical render path
Minimize the amount of time rendering files and content.

First view
What the user sees first. First meaningful paint.

Repeat view
What the user sees after and again. Crush those bytes and cache those requests.

Perceived performance
How fast does an user think a website is.

Time to interactive
Amount of time it takes for the website to be interactive.

Time to first byte
Measure amount of time between creating a connection to the server and downloading the contents of a web page / the first byte.


Compression

I used the npm package compression to compress the middleware of my application. So every rendered file will be compressed. You init it like:

import compression from "compression";

app.use(compression())

The difference between no compression and compression:
I only tested detail page, because homepage is pre-built.
Without compression:
without_compression_detail-page

With compression:
with_compression_detail-page

No shocking results, but all the little things count.

Caching

The performance is also optimized with caching, I've explained about caching in the Service worker section.

Without caching:
Homepage
without_caching_home-page
Detail page
with_compression_detail-page
With caching:
Homepage
with_caching_home-page
Detail page
with_caching_detail-page

The homepage has a very good result! Almost a whole second faster.

Lighthouse

With the lighthouse option in devTools you can test your website for performance, best practices, accessibility and Progressive Web App.

Homepage result

Mobile
lighthouse_result_homepage_mobile
Desktop
lighthouse_result_homepage_desktop

Detailpage result

This is the result on the detailpage:
Mobile
lighthouse_result_detail_mobile
Desktop
lighthouse_result_detail_desktop

The Progressive Web App emblem is present! 🎉

WebPageTest

Home page
webpagetest_home

Detail page
webpagetest_detail

npm packages used

Dependencies

devDependencies

APIs used

  • The OpenWeather map API
    With this API you can fetch weather data from all over the world. It has all different kind of fetches you can do. If you want 4 days forecast or just the current weather data, everything is possible.
  • Leaflet map
  • Unsplash API

API Response

This is what an API response looks like from The OpenWeather API

data = {
	clouds: {}, // The cloudiness in %
	coord: {},  // City geo location. Lon and lat
	dt: ,         // Last time when weather was updates in unix (UTC)
	id: ,         // The city ID
	main: {},   // The main weather information, temperature, feelslike, etc.
	name: ,       // City name
	sys: {},    // More about the country and timezone
	timezone: ,   // How many seconds difference from the UTC timezone
	visibility: , // The visiblity meter
	weather:[], // An array with weather objects containing weather information like description and id for icon
	wind: {}    // Information about the wind speed, degrees, etc.
}

Sources

License

MIT