Building Serverless Shiny Apps with webR: A Step-by-Step Guide

Using R without an R server. That’s what the buzz is all about lately. With webR, the R interpreter runs directly on the user’s machine, empowering you to run R code effortlessly in a web browser. While base R is great for standard data analysis, you will need packages for additional functionality. Luckily, webR supports loading R packages that have been pre-compiled to be used with webR. There is a small collection of supported packages readily available. One of the packages that will catch your eye is Shiny. So it’s time to see if we can run a Shiny app completely in the browser.

About webR

A couple of weeks ago I published a blog post on how to embed R in WordPress using webR. In that post, I explain in more detail what webR is about, how you can use it, and what kind of prerequisites you need to make this work in WordPress. The result was an R code snippet that could be executed on the client side. In this part two you’re stepping up your game: execute a Shiny app on the client side!

You will need the same prerequisites that are listed in part one, so it is wise to follow those steps first.

Note: I’m using WordPress, and will mention WordPress quite a few times, but you can use any website to run webR.

Your first Shiny app in the browser

First things first, the result. Below you will see a small demo Shiny app which allows you to play Hangman. This app doesn’t do any heavy work, but it does demonstrate how to run everything completely on the client.

⚠️ This works fine on Chrome, but Safari (macOS, not iOS) might struggle a bit depending on your version. Safari 15.6.1 returns a 404, Safari 16.5.1 loads the app but sometimes struggles to load some necessary JavaScript files.

⚠️ Loading webR might take a while 💤 

Do you want to know what’s happening? Open up the your web browser’s console and you can see some extra output!

Looking at the console output will not be enough to get the full details though, so let’s dive into it.

webR + Shiny: the main components

You only need three components to run a serverless Shiny app in WordPress:

  • HTML to insert into your webpage
  • A Service Worker script
  • The app.R script with your Shiny app in it

 

In the following sections we will take a closer look at each of these components.

The HTML

To add your Shiny app to any WordPress webpage, you need to insert some HTML. Alternatively you can also create a separate JavaScript file that you will source in. For simplicity, the following code snippet does everything at once:

				
					<button class="btn btn-success btn-sm" type="button" style="background-color: dodgerblue" id="statusButton">
  <i class="fas fa-spinner fa-spin"></i>
  Loading webR...
</button>
<div id="iframeContainer"></div>
<script defer src="https://use.fontawesome.com/releases/v5.15.4/js/all.js" integrity="sha384-rOA1PnstxnOBLzCLMcre8ybwbTmemjzdNlILg8O7z1lUkLXozs4DHonlDtnE7fpc" crossorigin="anonymous"></script>
<script type="module">

  import { WebR } from 'https://webr.r-wasm.org/latest/webr.mjs';

  const webR = new WebR();

  const shinyScriptURL = 'https://raw.githubusercontent.com/hypebright/webR_shiny/main/hangman.R'
  const shinyScriptName = 'app.R'

  let webSocketHandleCounter = 0;
  let webSocketRefs = {};


  const loadShiny = async () => {
    try {

      document.getElementById('statusButton').innerHTML = `
        <i class="fas fa-spinner fa-spin"></i>
        Setting up websocket proxy and register service worker
      `;

      // Create a proxy WebSocket class to intercept WebSocket API calls inside the
      // Shiny iframe and forward the messages to webR over the communication channel.
      class WebSocketProxy {
        url;
        handle;
        bufferedAmount;
        readyState;
        constructor(_url) {
          this.url = _url
          this.handle = webSocketHandleCounter++;
          this.bufferedAmount = 0;
          this.shelter = null;
          webSocketRefs[this.handle] = this;

          // Trigger the WS onOpen callbacks
          webR.evalRVoid(`
                        onWSOpen <- options('webr_httpuv_onWSOpen')[[1]]
                        if (!is.null(onWSOpen)) {
                          onWSOpen(
                            ${this.handle},
                            list(
                              handle = ${this.handle}
                            )
                          )
                        }
                        `)

          setTimeout(() => {
            this.readyState = 1;
            this.onopen()
          }, 0);
        }

        async send(msg) {
          // Intercept WS message and send it via the webR channel
          webR.evalRVoid(`
                        onWSMessage <- options('webr_httpuv_onWSMessage')[[1]]
                        if (!is.null(onWSMessage)) {
                          onWSMessage(${this.handle}, FALSE, '${msg}')
                        }
                        `)
        }
      }

      await webR.init();
      console.log('webR ready');

      // Read webR channel for events
      (async () => {
        for (; ;) {
          const output = await webR.read();
          switch (output.type) {
            case 'stdout':
              console.log(output.data)
              break;
            case 'stderr':
              console.log(output.data)
              break;
            case '_webR_httpuv_TcpResponse':
              const registration = await navigator.serviceWorker.getRegistration();
              registration.active.postMessage({
                type: "wasm-http-response",
                uuid: output.uuid,
                response: output.data,
              });
              break;
            case '_webR_httpuv_WSResponse':
              const event = { data: output.data.message };
              webSocketRefs[output.data.handle].onmessage(event);
              console.log(event)
              break;
          }
        }
      })();

      // Register service worker: note that this script needs to be uploaded to your site
      const registration = await navigator.serviceWorker.register('/httpuv-serviceworker.js', { scope: '/' })
        .catch((error) => {
          console.error('Service worker registration error:', error);
        });

      // Handy for trouble shooting
      if ('serviceWorker' in navigator) {
        navigator.serviceWorker.getRegistration()
          .then((registration) => {
            if (registration) {
              const scope = registration.scope;
              console.log('Service worker scope:', scope);
            } else {
              console.log('No registered service worker found.');
            }
          })
          .catch((error) => {
            console.error('Error retrieving service worker registration:', error);
          });
      } else {
        console.log('Service workers not supported.');
      }

      await navigator.serviceWorker.ready;
      window.addEventListener('beforeunload', async () => {
        await registration.unregister();
      });
      console.log("service worker registered");

      document.getElementById('statusButton').innerHTML = `
        <i class="fas fa-spinner fa-spin"></i>
        Downloading R script...
      `;
      await webR.evalR("download.file('" + shinyScriptURL + "', '" + shinyScriptName + "')");
      console.log("file downloaded");

      document.getElementById('statusButton').innerHTML = `
        <i class="fas fa-spinner fa-spin"></i>
        Installing packages...
      `;
      await webR.installPackages(["shiny", "jsonlite"])

      document.getElementById('statusButton').innerHTML = `
        <i class="fas fa-spinner fa-spin"></i>
        Loading app...
      `;

      webR.writeConsole(`
          library(shiny)
          runApp('` + shinyScriptName + `')
      `);

      // Setup listener for service worker messages
      navigator.serviceWorker.addEventListener('message', async (event) => {
        if (event.data.type === 'wasm-http-fetch') {
          var url = new URL(event.data.url);
          var pathname = url.pathname.replace(/.*\/__wasm__\/([0-9a-fA-F-]{36})/, "");
          var query = url.search.replace(/^\?/, '');
          webR.evalRVoid(`
                     onRequest <- options("webr_httpuv_onRequest")[[1]]
                     if (!is.null(onRequest)) {
                       onRequest(
                         list(
                           PATH_INFO = "${pathname}",
                           REQUEST_METHOD = "${event.data.method}",
                           UUID = "${event.data.uuid}",
                           QUERY_STRING = "${query}"
                         )
                       )
                     }
                     `);
        }
      });

      // Register with service worker and get our client ID
      const clientId = await new Promise((resolve) => {
        navigator.serviceWorker.addEventListener('message', function listener(event) {
          if (event.data.type === 'registration-successful') {
            navigator.serviceWorker.removeEventListener('message', listener);
            resolve(event.data.clientId);
            console.log("event data:")
            console.log(event.data)
          }
        });
        registration.active.postMessage({ type: "register-client" });
      });
      console.log('I am client: ', clientId);
      console.log("serviceworker proxy is ready");

      // Load the WASM httpuv hosted page in an iframe
      const containerDiv = document.getElementById('iframeContainer');
      let iframe = document.createElement('iframe');
      iframe.id = 'app';
      iframe.src = `./__wasm__/${clientId}/`;
      iframe.frameBorder = '0';
      iframe.style.width = '100%';
      iframe.style.height = '600px'; // Adjust the height as needed
      iframe.style.overflow = 'auto';
      containerDiv.appendChild(iframe);

      // Install the websocket proxy for chatting to httpuv
      iframe.contentWindow.WebSocket = WebSocketProxy;

      document.getElementById('statusButton').innerHTML = `
          <i class="fas fa-check-circle"></i>
          App loaded!
      `;
      document.getElementById('statusButton').style.backgroundColor = 'green';
      console.log("App loaded!");

    } catch (error) {
      console.log("Error:", error);
      document.getElementById('statusButton').innerHTML = `
        <i class="fas fa-times-circle"></i>
        Something went wrong...
      `;
      document.getElementById('statusButton').style.backgroundColor = 'red';
    }
  };

  loadShiny();

</script>
				
			

It is not as simple as loading webR and executing runApp(). That’s because a Shiny app is normally running on another port. You need to “intercept” this, and display the app in the current page instead. Hence the iframe where we source the content in from another URL. Also, Shiny needs to be able to communicate with the client. Messages need to be received and send. We need a service for that.

Let’s break the above script down:

  • HTML and button setup: the code starts with an HTML button element styled using Bootstrap classes. This button will be used to show the loading status of webR and the app. Additionally, an empty div with the ID “iframeContainer” is provided to hold the Shiny application iframe.
  • Importing dependencies: the script imports the necessary dependencies. In this case, it imports webR.
  • Setting up a WebSocket proxy: The code defines a WebSocketProxy class, which acts as a proxy to intercept WebSocket API calls inside the Shiny iframe and forwards the messages to webR over the communication channel. This setup enables seamless communication between the Shiny app and the R environment running in the browser.
  • Initializing webR: next, the script initializes the WebR environment and prepares it for communication.
  • Registering a Service Worker: the code registers a Service Worker for handling HTTP requests. This is essential for handling requests and responses between the Shiny app and R code. We will dive into the Service Worker in the next section.
  • Loading the Shiny script: the script downloads the R Shiny app script (hangman.R) from a public GitHub repository using the download.file() function and stores it with the name “app.R.” You don’t need to use GitHub, you can also upload the app.R file to your website directory and let webR download it to its working directory.
  • Installing required R packages: The necessary R package, “shiny”, is installed using the installPackages() function. You can add multiple packages here, as long as they are supported.
  • Running Shiny: the Shiny app is launched using the runApp() function.
  • Service Worker communication: The code sets up a listener to handle messages from the service worker. It processes the incoming HTTP requests from the Shiny app.
  • Loading Shiny in an iFrame: finally, the app is loaded into an iframe with the provided dimensions, allowing it to run and display in the web page.

The Service Worker

The fact that this is all working relies on the Service Worker script. I’m very grateful to George Stagg who created a httpuv serviceworker to make all of this possible!

				
					// code by George Stagg (https://github.com/georgestagg/shiny-standalone-webr-demo/blob/main/httpuv-serviceworker.js)

let wasmClientId;
let requests = {};

function promiseHandles() {
  const out = { resolve: (_) => {}, reject: (_) => {}, promise: null, };
  const promise = new Promise((resolve, reject) => {
    out.resolve = resolve;
    out.reject = reject;
  });
  out.promise = promise;
  return out;
}

function uuid() {
  return ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
    (c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
  );
}

const handleInstall = () => {
  console.log('Service worker installed');
  self.skipWaiting();
};

const handleActivate = () => {
  console.log('Service worker activated');
  return self.clients.claim();
};

const handleFetch = async (event) => {
  const wasmMatch = /\/__wasm__\/([0-9a-fA-F-]{36})/.exec(event.request.url);
  if (!wasmMatch) {
    return;
  }

  const wasmRequest = (async () => {
    const client = await self.clients.get(wasmMatch[1]);
    const id = uuid();
    requests[id] = promiseHandles();
  
    client.postMessage({
      type: "wasm-http-fetch",
      uuid: id,
      url: event.request.url,
      method: event.request.method,
      body: event.request.body,
    });

    const response = await requests[id].promise;
    const headers = Object.assign(
      { "Cross-Origin-Embedder-Policy": "require-corp" },
      Object.fromEntries(
        [...Array(response.headers.names.length).keys()].map((i) => {
          return [response.headers.names[i], response.headers.values[i].values[0]];
        })
      )
    );

    const body = response.body.type === 'raw'
      ? new Uint8Array(response.body.values)
      : response.body.values[0];
    return new Response( body, { headers } );
  })();

  event.waitUntil(wasmRequest);
  event.respondWith(wasmRequest);
};

const handleMessage = async (event) => {
  if (event.data.type == 'register-client') {
    const clientId = event.source.id;
    const client = await self.clients.get(clientId);
    client.postMessage({
      type: "registration-successful",
      clientId,
    });
  }
  if (event.data.type == 'wasm-http-response') {
    requests[event.data.uuid].resolve(event.data.response);
  }
}

self.addEventListener('install', handleInstall);
self.addEventListener('activate', handleActivate);
self.addEventListener('fetch', handleFetch);
self.addEventListener('message', handleMessage);
				
			

This script is a Service Worker designed to facilitate communication between the WebR environment and the Shiny app running in an iframe. Service workers are JavaScript files that run in the background of web pages and can intercept network requests, handle background tasks, and cache resources for offline use. Let’s break down the main functionalities of this service worker:

  • Initializing variables: the script initializes two variables, wasmClientId and requests. The wasmClientId will store the unique identifier of the WebR client running in the iframe. The requests object will store promises that resolve when a corresponding WebSocket response is received from the WebR environment.
  • Helper functions: the script defines three helper functions:
    • promiseHandles: This function returns an object containing a promise along with its resolve and reject functions. It is used to manage promises in the requests object.
    • uuid: This function generates a random unique identifier using a combination of random numbers and bit operations.
    • handleInstall, handleActivate, handleFetch, and handleMessage: these are all event listeners for the service worker’s lifecycle and fetch events.
  • Handling fetch requests: when a fetch event occurs (e.g., a network request is made from the Shiny app), the handleFetch function is triggered. It checks if the fetch request is for WebAssembly (aka Wasm) resources by matching the URL pattern /__wasm__/<unique_id>/. If it’s a Wasm request, the service worker creates a unique ID, stores a promise in the requests object, and posts a message to the corresponding WebR client (identified by the unique ID). The message contains the details of the fetch request.
  • Resolving fetch responses: the service worker listens for incoming messages using the message event. When a message with the type wasm-http-response is received, it means that the WebR environment has responded to a fetch request. The service worker resolves the promise associated with the unique ID stored in requests with the received response.
  • Client registration: when the Shiny app starts (or whenever a new client is created), it sends a message with the type register-client to the service worker. This message contains the client ID, which is the unique ID of the WebR client running in the iframe. The service worker replies with a registration-successful message, confirming the client registration.

 

Pfew, a lot of technical stuff! 

TL;DR: this service worker acts as a bridge between the Shiny app in the iframe and the WebR environment, allowing them to communicate efficiently using WebSockets. It intercepts fetch requests made by the Shiny app, forwards them to WebR, receives responses, and then resolves the corresponding promises to complete the fetch request lifecycle.

Adding a service worker to your WordPress site might seem daunting, but it’s straightforward. It comes down to adding a JavaScript file in your site’s file directory. Generally, you can follow these three simple steps:

  1. Create the service worker JavaScript file: Write your service worker logic in a JavaScript file. You can name it httpuv-serviceworker.js or any other desired name.
  2. Upload the Service Worker file: Upload the httpuv-serviceworker.js file to your WordPress site’s theme directory or any other directory where you want to store your JavaScript files. You can use an FTP client or the WordPress theme editor to upload the file. Normally you can access the site’s files via hosting service providers like SiteGround as well. If you’re using SiteGround simply head to “Site Tools” > “File Manager”.
  3. Reference the service worker file: to install and activate the service worker you need to register the script. In the code for this app we will do it like this: navigator.serviceWorker.register('./httpuv-serviceworker.js', { scope: '/' })

 

After completing these steps, the service worker JavaScript file will be loaded on your WordPress site, and the service worker will be registered. Note that service workers have limitations in WordPress due to its dynamic nature, so ensure that the service worker is compatible with your site’s setup and caching requirements.

Also be aware of the scope of the service worker. Many websites and blogs suggest to put the file into your theme directory, e.g. /public_html/wp-content/themes/neve/httpuv-serviceworker.js. This will limit the scope of the service worker to the theme only and you might get an error like “the path of the provided scope is not under the max scope allowed. Adjust the scope, move the Service Worker script, or use the Service-Worker-Allowed HTTP header to allow the scope” Putting the file in the root directory (/public_html) will make sure the script is in scope.

The Shiny app

The hangman game is word game where you need to guess a word. You can provide one letter at a time. Every time you have a correct letter, it puts it in the correct place. If you provide a letter that is not in the word, you loose a point. Normally you would literally draw a hanging man, but for the purposes of this demo that was a bridge too far 😉.

				
					library(shiny)

# Define a list of words for the game
words <- c("hangman", "programming", "shiny", "webr", "webassembly", "serviceworker")

ui <- fluidPage(
  
  titlePanel("Hangman Game"),
  
  mainPanel(
    h3("Guess the word!"),
    textOutput("word_display"),
    br(),
    textInput("guess_input", "Enter a letter:"),
    tagAppendAttributes(style = "background-color:steelblue;color:white;",
                        actionButton("guess_button", icon = icon("lightbulb"), "Guess")),
    br(),
    br(),
    h4("Incorrect Guesses:"),
    textOutput("incorrect_guesses"),
    br(),
    h4("Remaining Chances:"),
    textOutput("remaining_chances"),
    br(),
    tagAppendAttributes(style = "background-color:orange;color:white;",
                        actionButton("reset_button", icon = icon("redo"), "Reset"))
  )
)

server <- function(input, output, session) {
  # Initialize game state
  game_state <- reactiveValues(
    word = sample(words, 1),  # Randomly select a word from the list
    guessed_letters = character(0),  # Store guessed letters
    incorrect_guesses = 0,  # Count of incorrect guesses
    remaining_chances = 7  # Total chances before game over
  )
  
  # Function to update game state based on user guess
  update_game_state <- function() {
    
    guess <- tolower(substr(input$guess_input, 1, 1))  # Extract first character of user's guess
    
    if (guess %in% game_state$guessed_letters) {
      # Letter has already been guessed, do nothing
      return()
    }
    
    game_state$guessed_letters <- c(game_state$guessed_letters, guess)
    
    if (!(guess %in% strsplit(game_state$word, "")[[1]])) {
      # Incorrect guess
      game_state$incorrect_guesses <- game_state$incorrect_guesses + 1
    }
    
    if (game_state$incorrect_guesses >= game_state$remaining_chances) {
      # Game over
      showGameOverMessage()
    }
  }
  
  # Action when the guess button is clicked
  observeEvent(input$guess_button, {
    update_game_state()
  })
  
  # Function to display the word with guessed letters filled in
  output$word_display <- renderText({
    word <- game_state$word
    guessed_letters <- game_state$guessed_letters
    
    displayed_word <- sapply(strsplit(word, "")[[1]], function(x) {
      if (x %in% guessed_letters) {
        x
      } else {
        "_"
      }
    })
    
    paste(displayed_word, collapse = " ")
  })
  
  # Display incorrect guesses
  output$incorrect_guesses <- renderText({
    if(length(game_state$guessed_letters) == 0){
      "No incorrect guesses yet 👀 "
    } else {
      paste(game_state$guessed_letters[!(game_state$guessed_letters %in% strsplit(game_state$word, "")[[1]])], collapse = ", ")
    }
  })
  
  # Display remaining chances
  output$remaining_chances <- renderText({
    game_state$remaining_chances - game_state$incorrect_guesses
  })
  
  # Function to display game over message
  showGameOverMessage <- function() {
    showModal(modalDialog(
      title = "Game Over",
      paste("You ran out of chances! The word was", game_state$word),
      easyClose = TRUE
    ))
    
    # Reset game state
    game_state$word <- sample(words, 1)
    game_state$guessed_letters <- character(0)
    game_state$incorrect_guesses <- 0
  }
  
  observeEvent(input$reset_button, {
    
    game_state$word <- sample(words, 1)
    game_state$guessed_letters <- character(0)
    game_state$incorrect_guesses <- 0
    game_state$remaining_chances <- 7
    
    updateTextInput(session = session,
                    inputId = "guess_input",
                    value = "")
    
  })
}

shinyApp(ui, server)

				
			

Of course you can cheat now you’ve seen the words 😀.

Use cases, advantages, and limitations

While I don’t see full blown apps shipped like this (yet), I can see some benefits of webR in general and webR + Shiny in specific:

  • A simple demo is easily added to your website. Let users interact with your app or code directly.
  • It is great for teaching. No setup required, and code can be adjusted on the fly so people can see the results of their changes immediately.
  • Budget friendly: zero cost for setting up a server that runs R.
  • You can scale your Shiny applications to a wider audience. While you still might choose to deploy your Shiny app with R server, you can offload calculations to the client using webR. This way, you can allow more connections on the same R session.
  • The power of R in any web application 😎. Whether you want to run a statistical model in your React application, or do some good data manipulation in Vue.js: with webR it is possible!
  • Super fast deployment: update a script somewhere and your app will be updated. No Docker containers needed.

 

But, after a couple of weeks sitting on my Shiny + webR project, I also see some limitations:

  • Almost no documentation (but it’s coming!)
  • It’s slow, mainly due to the need for installing packages on the fly. But also spinning up the R session and activating the Service Worker take time. In the future (or even already?) there will hopefully be more clever and fast ways for that though.
  • It makes you wonder why you need Shiny. Sure, I love Shiny. But if you can use R in combination with Vue, React, or something else, why would you need Shiny at all?
  • Browser compatibility. Your completely reliant on the client. While it’s always true that you don’t have control over what browser someone is using, your now totally at the mercy of the browser. And since it’s still early days for webR, browser compatibility is not fantastic (yet).

 

Overall I think it’s fantastic that webR is here and I like to keep an eye on it to see how it evolves. It’s definitely getting traction and this also means that developers will jump on it to make it even better. Who knows what it will be in a couple of months 👀.

Future work

The app I originally wanted to add to this website made a request to the Yahoo Finance API to retrieve some AEX stock data using the jsonlite package. Unfortunately, it is not that easy to call that API due to CORS restrictions. It led to strange R related errors too: “Error: evaluation nested too deeply: infinite recursion / options(expressions=)?”. I’m hoping to use another API in the future, to at least demonstrate how you can fetch data from somewhere else.

Another thing that would improve webR Shiny apps drastically is the availability of more UI packages. I bet that will not take a long time…

A big thanks to the open source community!

Where would we be without the R community. There are some amazing webR + Shiny resources out there:

 

Wrap up

In this article you hopefully learned a bit more about webR + Shiny, its use cases, and how to run a Shiny app in the browser with webR!

⚠️ webR is new and currently under development, the API is not stable yet and might change- meaning these examples might stop working at some stage. Keep an eye on the documentation page for the latest news!

☕️ Was this useful to you and would you like to support me? You can buy me a coffee!

I provide R and Shiny consultancy. Do you need help with a project? Reach out and let’s have a chat!

Leave a Reply

Your email address will not be published. Required fields are marked *