Mozilla

From Web App to Progressive Web App

In 30 minutes!

  1. Introducing Pangolink!
  2. Client-server architecture.
  3. Getting offline!
  4. Integration with the mobile platform.
  5. Receiving Push notifications
  6. Resources

Introducing Pangolink!

Pangolink! is a cross platform learning-focused web application. Its repository includes the proper instructions for following the code.


Client-server architecture

Client-server architecture

The server is responsible of serving the web application and commuinicate with the client via websockets. A small REST API is provided to upload the files.

REST API to post files.


app.post('/api/files', upload.single('file'), (req, res) => {
  var destinatary = req.body.destinatary;
  res.status(201).json({ destinatary });
  originalNames[req.file.filename] = req.file.originalname;
  enqueueFile(req.file, destinatary);
  sendPendingFiles(destinatary);
});
              

Every time the server receives a file, it enqueues the file for the destinatary.


app.post('/api/files', upload.single('file'), (req, res) => {
  var destinatary = req.body.destinatary;
  res.status(201).json({ destinatary });
  originalNames[req.file.filename] = req.file.originalname;
  enqueueFile(req.file, destinatary);
  sendPendingFiles(destinatary);
});
              

A static server to get the files.


app.use(
  '/uploads',
  express.static('uploads', {
    setHeaders: customContentDisposition
  })
);
              

And websockets for presence…


socket.on('identify', id => {
  console.log(`${id} connected to Pangolink.\n`);
  clients[id] = socket;
  socket.on('disconnect', () => {
    delete clients[id];
    console.log(`Lost connection with ${id}`);
  });

  sendPendingFiles(id);
});
              

…and signals about new incoming files.


function sendPendingFiles(id) {
  queues[id] = queues[id] || [];
  var socket = clients[id];
  if (socket) {
    queues[id].forEach(file => socket.emit('file', {
      url: '/uploads/' + file.filename,
      originalname: file.originalname,
      mimetype: file.mimetype,
      size: file.size
    }));
    queues[id] = [];
  }
}
              
HTTPS tunnel

All the enhancements we are going to explore requires HTTPS so I'm using ngrok with custom names to provide a secure tunnel to my local running node.js 6.x server.

Getting offline!

See the diff

To do that, we are going to use Service Workers and caches.

Want to know more? Get your 101!

Fetch event

Register the service worker


if ('serviceWorker' in navigator) {
  navigator.serviceWorker.register('sw.js');
}
              

Hook into the registration process


this.oninstall = evt => {
  var precacheAssets = caches.open(PANGOLINK_CACHE)
    .then(cache => cache.addAll(ASSETS));

  evt.waitUntil(precacheAssets);
};
              

Store the assets


this.oninstall = evt => {
  var precacheAssets = caches.open(PANGOLINK_CACHE)
    .then(cache => cache.addAll(ASSETS));

  evt.waitUntil(precacheAssets);
};
              

Wait for the assets to be completely cached


this.oninstall = evt => {
  var precacheAssets = caches.open(PANGOLINK_CACHE)
    .then(cache => cache.addAll(ASSETS));

  evt.waitUntil(precacheAssets);
};
              

Handle fetch event…


this.onfetch = evt => {
  var request = evt.request;
  var url = new URL(request.url);
  if (request.method === 'GET' && isAsset(url)) {
    var cacheResponse = fromCache(request);
    var networkResponse = fromNetwork(request);
    evt.respondWith(race(
      cacheResponse,
      networkResponse.then(response => response.clone())
    ));
    evt.waitUntil(
      networkResponse.then(response => update(request, response.clone()))
    );
  }
};
              

Handle just in case we request an asset


this.onfetch = evt => {
  var request = evt.request;
  var url = new URL(request.url);
  if (request.method === 'GET' && isAsset(url)) {
    var cacheResponse = fromCache(request);
    var networkResponse = fromNetwork(request);
    evt.respondWith(race(
      cacheResponse,
      networkResponse.then(response => response.clone())
    ));
    evt.waitUntil(
      networkResponse.then(response => update(request, response.clone()))
    );
  }
};
              

Query both cache and network. Let them compete for the response…


this.onfetch = evt => {
  var request = evt.request;
  var url = new URL(request.url);
  if (request.method === 'GET' && isAsset(url)) {
    var cacheResponse = fromCache(request);
    var networkResponse = fromNetwork(request);
    evt.respondWith(race(
      cacheResponse,
      networkResponse.then(response => response.clone())
    ));
    evt.waitUntil(
      networkResponse.then(response => update(request, response.clone()))
    );
  }
};
              

But wait for the network response and update the cache entry.


this.onfetch = evt => {
  var request = evt.request;
  var url = new URL(request.url);
  if (request.method === 'GET' && isAsset(url)) {
    var cacheResponse = fromCache(request);
    var networkResponse = fromNetwork(request);
    evt.respondWith(race(
      cacheResponse,
      networkResponse.then(response => response.clone())
    ));
    evt.waitUntil(
      networkResponse.then(response => update(request, response.clone()))
    );
  }
};
              

The app is offline ready but please, inform the user.


window.addEventListener('offline', () => offlineUi.show());
window.addEventListener('online', () => offlineUi.hide());
offlineUi[navigator.onLine ? 'hide' : 'show']();
              

Integrating with the mobile platform

See the diff

{
  "name": "Pangolink!",
  "short_name": "Pangolink!",
  "start_url": "/",
  "description": "The ephemeral share service",
  "icons": [
    {
      "src": "/icons/192x192.png",
      "sizes": "192x192",
      "type": "image/png"
    }
  ],
  "orientation": "portrait",
  "display": "standalone",
  "theme_color": "#FCE100",
  "background_color": "#FCE100"
}

Prompting the user is not an standard, this is what Google thinks it's best but other vendors are experimenting with different approaches.

This is just a regular web site.

But shown in a special way.

Getting notifications

See the diff

Push event

After registering the service worker, subscribe.


function startApp() {
  if (swRegistration) {
    return swRegistration
      .then(subscribe)
      // Send the endpoint to the server
      .then(subscription => pangolink.connect(subscription.endpoint));
  }
  return pangolink.connect();
}

Use the browser API to ask for an endpoint.


function subscribe(registration) {
  return registration.pushManager.getSubscription()
    .then(subscription => {
      if (subscription) {
        return subscription;
      }
      return registration.pushManager.subscribe({ userVisibleOnly: true });
    });
}
            

If it already exists, use that…


function subscribe(registration) {
  return registration.pushManager.getSubscription()
    .then(subscription => {
      if (subscription) {
        return subscription;
      }
      return registration.pushManager.subscribe({ userVisibleOnly: true });
    });
}
            

…if not, ask for one.


function subscribe(registration) {
  return registration.pushManager.getSubscription()
    .then(subscription => {
      if (subscription) {
        return subscription;
      }
      return registration.pushManager.subscribe({ userVisibleOnly: true });
    });
}
            

Now, send the endpoint to your server!


function startApp() {
  if (swRegistration) {
    return swRegistration
      .then(subscribe)
      // Send the endpoint to the server
      .then(subscription => pangolink.connect(subscription.endpoint));
  }
  return pangolink.connect();
}
            

In the server, we keep the endpoint by id.


socket.on('identify', ({id, pushEndpoint}) => {
  console.log(
    `${id} connected to Pangolink with push endpoint ${pushEndpoint}\n`
  );
  clients[id] = socket;
  endpoints[id] = pushEndpoint;
  socket.on('disconnect', () => {
    delete clients[id];
    console.log(`Lost connection with ${id}`);
  });

  sendPendingFiles(id);
});
            

Do you remember we send pending files if there were a socket?


function sendPendingFiles(id) {
  queues[id] = queues[id] || [];
  var socket = clients[id];
  if (socket) {
    queues[id].forEach(file => socket.emit('file', {
      url: '/uploads/' + file.filename,
      originalname: file.originalname,
      mimetype: file.mimetype,
      size: file.size
    }));
    queues[id] = [];
  }
}
            

This time, I'm going to send the Push notification always.


              function sendPendingFiles(id) {
  queues[id] = queues[id] || [];

  var pushEndpoint = endpoints[id];
  if (pushEndpoint && queues[id].length > 0) {
    webPush.sendNotification(pushEndpoint, { ttl: 24 * 60 * 60 })
    .catch(reason => console.error(reason));
    console.log(`Notification of incoming files sent to ${pushEndpoint}\n`);
  }

  /* ... */

}
            

Finally, in the service worker, we handle the incoming Push notification.


this.onpush = evt => {
  evt.waitUntil(handlePushNotification());
};

function handlePushNotification() {
  return self.registration.showNotification('New incoming files', {
    body: 'You have new files ready to download',
    icon: '/icons/192x192.png',
    tag: 'file'
  });
}
            

Resources

me

Salvador de la Puente González

@salvadelapuente

http://github.com/delapuente

https://delapuente.github.io/presentations/

## Questions?