This application shows you the weather for all over the world! With the interactive map you can search all over the world.
Live link to the application: progressive-weatherapp.herokuapp.com
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!
- 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
$ 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
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
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");
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}
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/"))
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.
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"
}]
}
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");
}
}
I've used multiple optimizing methods, like compression and caching.
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.
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:
No shocking results, but all the little things count.
The performance is also optimized with caching, I've explained about caching in the Service worker section.
Without caching:
Homepage
Detail page
With caching:
Homepage
Detail page
The homepage has a very good result! Almost a whole second faster.
With the lighthouse option in devTools you can test your website for performance, best practices, accessibility and Progressive Web App.
Detailpage result
This is the result on the detailpage:
Mobile
Desktop
The Progressive Web App emblem is present! π
- @babel/core
- body-parser
- compression
- dotenv
- ejs
- express
- file-system
- gulp
- gulp-babel
- gulp-clean-css
- gulp-concat
- gulp-rollup
- gulp-uglify
- node-fetch
- pug
- rimraf
- localhost-logger <-- My own npm package! π
- 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
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.
}
- Teacher: Declan Rek - Voorhoede
- Declan's presentation about critical rendering path
- Student assistent: Wouter van der Heijde - CMD minor
- Web.dev about adding a manifest
- More about service workers
- clients.claim()
- client.postMessage()
- Time to first byte