Upgrade to Pro — share decks privately, control downloads, hide ads and more …

Yes, Rails Does Support PWAs

Yes, Rails Does Support PWAs

Progressive Web Apps are a constellation of conventions. Those conventions fit neatly into Rails, without the need to introduce a complicated Javascript front end. By embracing core Rails technologies like ActiveJob, ActionCable, Russian Doll Caching, and sprinkles of Stimulus, you can deliver powerful and immersive front end web apps. You can read more here: https://johnbeatty.co/2019/05/06/hnpwa-with-rails-and-stimulus-introduction/

John Beatty

April 30, 2019
Tweet

Other Decks in Programming

Transcript

  1. Yes, Rails Does
    Support PWAs
    John Beatty – Railsconf 2019

    View Slide

  2. About Me
    • Started developing iOS, Android, and Blackberry apps in 2010
    • Learned Rails to build backend APIs and CMSes for these apps

    View Slide

  3. About Me
    • Started developing iOS, Android, and Blackberry apps in 2010
    • Learned Rails to build backend APIs and CMSes for these apps
    • Worked at various startups and independently in the DC area
    • IT Director and CS Teacher at a small school
    • Blog at johnbeatty.co

    View Slide

  4. About You!
    • You’re going to become Rails - PWA advocates
    • No more handwaving!
    • You’ll be able to argue for a full Rails app instead of a Rails API
    and a something-something-dot-js front end

    View Slide

  5. About You!
    • You’re going to become Rails - PWA advocates
    • No more handwaving!
    • You’ll be able to advocate for a full Rails app instead of a Rails
    API and a something-something-dot-js front end
    • Let’s build a version of the Hacker News Progressive Web App
    • https://hnpwa.com
    • Spiritual successor to TodoMVC

    View Slide

  6. P What?
    • Progressive Web Apps are a convention
    • A very simple convention
    • index.html
    • manifest.json
    • service-worker.js

    View Slide

  7. PWA – index.html



    The World's Fastest PWA!




    !

    View Slide

  8. PWA – index.html

    The World's Fastest PWA!
    This is the one!!
    <br/>if (navigator.serviceWorker) {<br/>navigator.serviceWorker.register('/worlds-fastest-pwa/service-worker.js',<br/>{ scope: '/worlds-fastest-pwa/' })<br/>.then(function(reg) {<br/>console.log('[Companion]', 'Service worker registered!');<br/>console.log(reg);<br/>});<br/>}<br/>!
    !
    !

    View Slide

  9. PWA – manifest.json
    {
    "short_name": "FAST",
    "name": "The Worlds Fastest PWA",
    "icons": [
    {
    "src": "/worlds-fastest-pwa/icon_192.png",
    "type": "image/png",
    "sizes": "192x192"
    },
    {
    "src": "/worlds-fastest-pwa/icon_512.png",
    "type": "image/png",
    "sizes": "512x512"
    }
    ],
    "start_url": "/worlds-fastest-pwa/",
    "background_color": "#3367D6",
    "display": "standalone",
    "scope": "/worlds-fastest-pwa/",
    "theme_color": "#C50001"
    }

    View Slide

  10. PWA – service-worker.js

    View Slide

  11. PWA – service-worker.js
    var CACHE_VERSION = 'v1';
    var CACHE_NAME = CACHE_VERSION + ':sw-cache-';
    function onInstall(event) {
    console.log('[Serviceworker]', "Installing!", event);
    event.waitUntil(
    caches.open(CACHE_NAME).then(function prefill(cache) {
    return cache.addAll([
    '/worlds-fastest-pwa/offline.html',
    ]);
    })
    );
    }

    View Slide

  12. PWA – service-worker.js
    function onActivate(event) {
    console.log('[Serviceworker]', "Activating!", event);
    event.waitUntil(
    caches.keys().then(function(cacheNames) {
    return Promise.all(
    cacheNames.filter(function(cacheName) {
    !// Return true if you want to remove this cache,
    !// but remember that caches are shared across
    !// the whole origin
    return cacheName.indexOf(CACHE_VERSION) !!!== 0;
    }).map(function(cacheName) {
    return caches.delete(cacheName);
    })
    );
    })
    );
    }

    View Slide

  13. PWA – service-worker.js
    function onFetch(event) {
    event.respondWith(
    !// try to return untouched request from network first
    fetch(event.request).catch(function() {
    !// if it fails, try to return request from the cache
    return caches.match(event.request).then(function(response) {
    if (response) {
    return response;
    }
    !// if not found in cache, return default offline content for navigate
    requests
    if (event.request.mode !!=== 'navigate' !||
    (event.request.method !!=== 'GET' !&&
    event.request.headers.get('accept').includes('text/html'))) {
    return caches.match('/worlds-fastest-pwa/offline.html');
    }
    })
    })
    );
    }

    View Slide

  14. PWA – service-worker.js
    self.addEventListener('install', onInstall);
    self.addEventListener('activate', onActivate);
    self.addEventListener('fetch', onFetch);

    View Slide

  15. PWA – resources
    • Microsoft’s PWA builder
    • provides a quick test of what you can add to an existing site to
    make it a PWA
    • https://www.pwabuilder.com
    • Service Worker Cookbook
    • https://serviceworke.rs

    View Slide

  16. PWA – resources
    • Google’s Lighthouse Audit Test
    • Audit your website in Chrome
    • World’s Fastest PWA
    • No fancy frameworks
    • 100/100 on Google’s Lighthouse test
    • https://github.com/johnbeatty/worlds-fastest-pwa
    • https://johnbeatty.github.io/worlds-fastest-pwa/

    View Slide

  17. World’s Fastest PWA
    johnbeatty.github.io/worlds-fastest-pwa

    View Slide

  18. World’s Fastest PWA
    online
    offline

    View Slide

  19. Why?
    • It’s a new convention to solve a common problem
    • Do we need a mobile app?
    • Restaurant Ordering with push notifications
    • Store offline information

    View Slide

  20. How
    • If we want to match the perceived performance of a mobile app
    • We need to make things feel fast
    • Let’s use a bunch of existing rails conventions!

    View Slide

  21. How
    • Stimulus
    • ActionCable
    • ActiveJob
    • Russian Doll Caching
    • HTML everywhere

    View Slide

  22. HNPWA
    • Let’s build a version of the Hacker News Progressive Web App
    • https://hnpwa.com
    • Spiritual successor to TodoMVC
    • https://github.com/johnbeatty/hnpwa-app

    View Slide

  23. Don’t Block The Main
    Thread
    • In iOS development, we had a rule “Don’t block the main
    thread”
    • Web apps need to adopt this rule too
    • Load the bare minimum, and then let data load in from
    background

    View Slide

  24. Don’t Block The Main
    Thread
    • Turbolinks helps by loading the CSS and Javascript only once
    • ActionCable loads data as it’s available, so the request can send
    the skeleton HTML first
    • Any background tasks, such as calls to third party APIs, can be
    put into ActiveJob
    • The service worker caches files locally

    View Slide

  25. The HNPWA
    USING RAILS AS THE FRONT END

    View Slide

  26. HNPWA
    • Attempting to highlight a fast, reliable, and engaging experience
    • https://hnpwa.com
    • https://github.com/tastejs/hacker-news-pwas

    View Slide

  27. HNPWA
    • A spiritual successor to TODOMVC
    • Trying to compare different tech stacks
    • Can differ on based on server infrastructure and performance
    patterns

    View Slide

  28. The HNPWA built with Rails

    View Slide

  29. HNPWA
    It scored 100s on Google’s Lighthouse test

    View Slide

  30. HNPWA
    Emerging Markets Mobile Test

    View Slide

  31. Hacker News API
    • JSON API provides all the data
    • https://github.com/HackerNews/API
    • 5 endpoints are used to pull Item ids from Hacker News
    • An Item endpoint provides all the information for each id

    View Slide

  32. Hacker News API
    • Item
    • Contain all the displayable information we care about
    • User
    • Contains username and bio

    View Slide

  33. Modeling
    The Hacker News API
    Item
    TopItem NewItem ShowItem AskItem JobItem
    Each parent item refers to an Item, since an Item could be from Top, New, Show, or Ask at the same time

    View Slide

  34. Modeling
    The Hacker News API
    Item
    TopItem
    Each parent item records its order as received from the API so that it can match how it appears on Hacker News
    Item
    TopItem
    Item
    TopItem
    Location 0
    Location 1
    Location 2

    View Slide

  35. Modeling
    The Hacker News API
    Item
    Comments are represented as children items
    Item Item
    Item Item

    View Slide

  36. Fetching
    The Hacker News API
    • There are five end points like this:
    • /top loads /v0/topstories in LoadTopItemsJob
    • /new loads /v0/newstories in LoadNewItemsJob
    • /ask loads /v0/askstories in LoadAskItemsJob
    • /show loads /v0/showstories in LoadShowItemsJob
    • /job loads /v0/jobstories in LoadJobItemsJob
    • Each Job is scheduled by the whenever gem

    View Slide

  37. Fetching
    The Hacker News API
    • The /top page could visit the APIs /v0/topstories endpoint and
    gets back up 500 different ids
    • Then it needs to make 500 requests to /v0/item/ for
    each id
    • Even when paginated, this is a lot of API traffic
    • If it’s put into a job that runs on a regular interval, the app
    won’t block the “main thread”

    View Slide

  38. Fetching
    The Hacker News API
    class LoadTopItemsJob < ApplicationJob
    queue_as :default
    def perform(*args)
    top_stories_json = JSON.parse Http.get("https:!//hacker-news.firebaseio.com/v0/
    topstories.json").to_s
    top_stories_json.each_with_index do |hn_story_id, top_news_location|
    LoadTopItemJob.perform_later top_news_location, hn_story_id
    end
    TopItem.where("location !>= ?", top_stories_json.length).destroy_all
    end
    end
    app/jobs/load_top_items_job.rb

    View Slide

  39. Fetching
    The Hacker News API
    • Each of these id fetching jobs spawns more individual job
    • LoadTopItemJob fetches the details for an Item, stores the
    results, and then passes the information to anyone listening for
    that item on the front end over an ActionCable connection
    • Caches the Item into an HTML partial
    • Repeat for LoadNewItemJob, LoadShowItemJob,
    LoadAskItemJob, and LoadJobItemJob

    View Slide

  40. Fetching
    The Hacker News API
    class LoadTopItemJob < ApplicationJob
    queue_as :default
    def perform(top_news_location, hn_story_id)
    story_json = JSON.parse Http.get("https:!//hacker-news.firebaseio.com/v0/item/
    !#{hn_story_id}.json?print=pretty").to_s
    # populate Item and TopItem model from story_json
    ActionCable.server.broadcast "TopItemChannel:!#{top_item.location}", {
    message: TopsController.render( top_item.item ).squish,
    location: top_item.location
    }
    ActionCable.server.broadcast "ItemsListChannel:!#{top_item.item.id}", {
    item: ItemsController.render( top_item.item ).squish,
    item_id: top_item.item.id
    }
    end
    end
    app/jobs/load_top_item_job.rb

    View Slide

  41. Displaying
    The Hacker News API
    • Everything is rendered server side in HTML
    • Using Russian Doll Caching to deliver HTML really quickly,
    despite N+1 stucture

    View Slide

  42. Displaying
    The Hacker News API
    class TopsController < ApplicationController
    def show
    @page = params[:page] ? params[:page].to_i : FIRST_PAGE
    @total_pages = TopItem.count / ITEMS_PER_PAGE
    @top_item = TopItem.order(:updated_at).last
    @top_items =
    TopItem.order(:location).limit(ITEMS_PER_PAGE).offset(@page *
    ITEMS_PER_PAGE).includes(:item)
    end
    end
    app/controllers/tops_controller.rb

    View Slide

  43. Displaying
    The Hacker News API

    data-controller="item-location items"
    data-item-location-channel="TopItemChannel">






    !
    !

    app/views/tops/show.html.erb

    View Slide

  44. Displaying
    The Hacker News API



    !

    app/views/top_items/_top_item.html.erb

    View Slide

  45. Displaying
    The Hacker News API



    !!!
    !

    !!!
    !

    !!!
    !
    !

    app/views/items/_item.html.erb

    View Slide

  46. Displaying
    The Hacker News API
    • By aggressively caching the content as it’s received, and using
    the Russian doll caching, page loads can feel instantaneous
    • It feels like the app isn’t blocking the main thread
    • This caching technique is used on the other pages

    View Slide

  47. Refreshing
    The Hacker News API
    • What happens as the background jobs pull in new information?
    • Send it over a websocket!

    View Slide

  48. Refreshing
    The Hacker News API
    • Each page uses listens for new items
    • Stimulus manages subscribing and closing the ActionCable
    subscriptions as a reader navigates through the site.

    View Slide

  49. Refreshing
    The Hacker News API
    • There are two different channels on the /top page,
    • ItemsListChannel: Listen for updates to an individual item
    • TopItemChannel: Listen for updates to a location change
    • Asynchronous updates make the web app feel performant

    View Slide

  50. Subscribing
    To ActionCable Channels
    • Stimulus Controllers connect to ActionCable to listen to changes
    to items.

    View Slide

  51. Subscribing
    To ActionCable Channels
    data-controller="item-location items"
    data-item-location-channel="TopItemChannel">
    app/views/tops/show.html.erb


    !
    app/views/top_items/_top_item.html.erb

    View Slide

  52. Subscribing
    To ActionCable Channels
    app/javascript/controllers/item_location_controller.js
    import { Controller } from "stimulus"
    import createChannel from "cables/cable";
    export default class extends Controller {
    initialize() {
    let thisController = this;
    this.channel = createChannel( this.data.get("channel"), {
    connected() {
    thisController.listen()
    },
    received({ message, location }) {
    let existingItem = document.querySelector(`[data-location='${ location }']`)
    if (existingItem) {
    existingItem.innerHTML = message
    }
    }
    });
    }
    }

    View Slide

  53. Subscribing
    To ActionCable Channels
    app/javascript/controllers/item_location_controller.js
    connect() {
    this.listen()
    }
    disconnect() {
    if (this.channel) {
    this.channel.perform('unfollow')
    }
    }
    listen() {
    if (this.channel) {
    let locations = []
    for (const value of document.querySelectorAll(`[data-location]`)) {
    locations.push( value.getAttribute('data-location') )
    }
    this.channel.perform('follow', { locations: locations } )
    }
    }
    }

    View Slide

  54. Subscribing
    To ActionCable Channels
    app/channels/top_item_channel.rb
    class TopItemChannel < ApplicationCable!::Channel
    def follow(data)
    stop_all_streams
    locations = data['locations']
    unless locations.nil?
    locations.each do |location|
    stream_from "TopItemChannel:!#{location}"
    end
    end
    end
    def unfollow
    stop_all_streams
    end
    end

    View Slide

  55. PWA
    The Hacker News API
    • How do we make this really fast web app a PWA now?
    • Where do the PWA conventions fit into a Rails app?

    View Slide

  56. PWA
    The Hacker News API
    Rails.application.routes.draw do
    get '/service-worker.js' !=> "service_worker#service_worker"
    get '/manifest.json' !=> "service_worker#manifest"
    get '/offline.html' !=> "service_worker#offline"
    end
    config/routes.rb

    View Slide

  57. PWA
    The Hacker News API
    class ServiceWorkerController < ApplicationController
    protect_from_forgery except: :service_worker
    def service_worker
    end
    def manifest
    end
    def offline
    end
    end
    app/controllers/service_worker_controller.rb

    View Slide

  58. {
    "short_name": "HNWPA",
    "name": "Hacker News Progressive Web App",
    "icons": [
    {
    "src": "",
    "type": "image/png",
    "sizes": "192x192"
    },
    {
    "src": "",
    "type": "image/png",
    "sizes": "512x512"
    }
    ],
    "start_url": "",
    "background_color": "#f2f3f5",
    "display": "standalone",
    "scope": "",
    "theme_color": "#f60"
    } app/views/service_worker/manifest.json.erb

    View Slide

  59. var CACHE_VERSION = 'v1';
    var CACHE_NAME = CACHE_VERSION + ':sw-cache-';
    function onInstall(event) {
    console.log('[Serviceworker]', "Installing!", event);
    event.waitUntil(
    caches.open(CACHE_NAME).then(function prefill(cache) {
    return cache.addAll([
    '',
    '',
    '/offline.html',
    ]);
    })
    );
    }
    app/views/service_worker/service-worker.js.erb

    View Slide

  60. PWAs Are Easy
    • We just described a fast web app that is is built entirely on Rails
    defaults
    • Turbolinks let’s the app only send HTML after the first load
    • Russian Doll Caching speeds up each request
    • ActiveJob keeps unnecessary work out of the request response
    cycle
    • ActionCable loads data as its ready
    • Stimulus adds the sprinkles of interactivity

    View Slide

  61. PWAs Are Easy
    • We just described a fast web app that is is built entirely on Rails
    defaults
    • Turbolinks let’s the app only send HTML after the first load
    • Russian Doll Caching speeds up each request
    • ActiveJob keeps unnecessary work out of the request response
    cycle
    • ActionCable loads data as its ready
    • Stimulus adds the sprinkles of interactivity

    View Slide

  62. You’re a PWA Native
    • Speed up your web app by not blocking the “main thread”
    • PWAs can now replace android apps
    • This might be something to consider in light of Turbolinks’
    Native Android wrapper in the midst of a rewrite
    • You’re now able to cut through the front end fog, and keep it
    Rails.

    View Slide

  63. Thank you!
    @jpbeatty
    https://johnbeatty.co

    View Slide