Site icon R-bloggers

Build serverless shiny application via Github page

[This article was first published on R-posts.com, and kindly contributed to R-bloggers]. (You can report issue about the content on this page here)
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.

Simple guide for simple shiny application 

TL;DR

I made shiny application in github page with quarto.

You can check code in my github repository and result, result2


How we use shiny

Shiny is R package to make user utilize R with web browser without install it.

So my company utilizes shiny to provide statistical analysis for doctors (who don’t know R but need statistics).


Behind shiny

As you know, shiny is consisted with 2 part. UI and Server

You may think just UI is channel to both get input (data) from user and return calculated output (result) to user.
and server is just calculator

It means, server requires dynamic calculation that may change, not fixed contents (it called as static web page)

To achieve dynamic calculation, there are several options.

We can use shinyapps.io, posit connect, or deploy own shiny server in other cloud like AWS / azure / GCP …

These options can be categorized into two main categories: free but with limited features, or feature-rich but paid.

There is no single right answer, but I use shinyapps.io in see toy level project or deploy using shiny server in company’s cloud server which is not just toy level.


The rise of webR


Recent, webassembly (wasm) has emerged. that is use programming language in web browser (like Chrome) without install it (via javascript)

As far as I know, webR (R version of wasm) is built from late 2022 and some Examples are being shared to make R available on the web.

I understand logic for webR like just below figure. (but understanding is not necessary to run)


Shiny with wasm

For shiny, there is already wasm application called shinylive. but it utilizes shiny for Python.

Personally, I’m not familiar with this. Since I used R for a long time
so I wasn’t interested in this.

but very recent, The article has been shared with appsilon’s shiny weekly newsletter.

and Leemput explains how to implant webR and shiny application in WordPress very kindly.

Since wordpress provides a static page service, this means that shiny can be planted using the github page I’m familiar with. (There are examples with netlify. so I think static page service like Vercel, Notion, Firebase or even medium may use webR)

The logic is just below. (as I understand)

note, Main difference with webR and shiny wasm is service worker



Let’s Build it

To build serverless shiny application with github page, we need 3 + 1 things.

1. HTML contents (button to show status of wasm and iframe for shiny applicaiton)

2. shiny code (app.R, we’ll utilize pre-made and publicly avaiable app)

3. javascript to run service worker (web worker + serivce worker)

and hard thing.

4. configuration for github page to utilize service worker via proxy.

we can utilize the resources provided by Leemput. (HTML contents and javascript code)

so let’s make index.qmd like below (you can check in my repo too)

Important: Change html to “{=html}”. I changed it since it breaks wordpress site.

---
title: "serverless shiny with github page"
include-in-header:
text: | 
  <script> type='application/javascript' src = 'enable-threads.js' </script>
---

```html
<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.awesome.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();
  // TODO
  const shinyScriptURL = '<https://raw.githubusercontent.com/rstudio/shiny/main/inst/examples/01_hello/app.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`;
      class WebSocketProxy {
        url;
        handle;
        bufferedAmount;
        readyState;
        constructor(_url) {
          this.url = _url
          this.handle = webSocketHandleCounter++;
          this.bufferedAmount = 0;
          this.shelter = null;
          webSocketRefs[this.handle] = this;
          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) {
          webR.evalRVoid(`
          onWSMessage <- options('webr_httpuv_onWSMessage')[[1]]
          if (!is.null(onWSMessage)) {onWSMessage(${this.handle}, FALSE, '${msg}')}
          `)
        }
      }
      await webR.init();
      console.log('webR ready');
      (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;
          }
        }
      })();
      // TODO
      const registration = await navigator.serviceWorker.register('/wasmR/httpuv-serviceworker.js', { scope: '/wasmR/' }).catch((error) => {
      console.error('Service worker registration error:', error);
      });
      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>
```

note that, there is 4 code that you must notice.

  1. Line 3–4 add header to enable-thread.js : github page has some permissions-policy (CORB / COOP / COEP) that blocks resource from other source page. so add this to enable it. 
  2. Line 7 add “=html” to HTML code in quarto (not just show it)
  3. First TODO set app.R code via URL: I tried to include in repo and call it like repo/app.R, but it didn’t work. so upload app.R in your repo and call it with raw file URL
  4. Last TODO register service worker along your github page:
    in registration, above code use /wasmR/httpuv-serviceworker.js and scope /wasmR/ but you must change to wasmR as your repository name.

after complete index.qmd. render it to index.html

but result will not show in localhost.

Remain step is so easy.

  • just commit your work to repository,
  • and build github page with that.
  • In page setting, do not use /docs, just / (root) only worked for me. even set quarto project to render output in /docs

Final repository structure

/repo
  - index.qmd
  - index.html
  - /index_files 
  - enable-thread.js
  - httpuv-serviceworker.js
  - …. (readme.md and so on)

bold is essential file and js file should download from link 1, 2.


Summary

We’ve seen how to deploy shiny as a github page with a simple example.

Let’s summarize some of the pros and cons of this method.

Pros
  1. You can deploy simple shinyapp with static page (github page)
  2. You don’t need to consider cost / scale / performance since wasm uses client (User) ‘s PC.
  3. You can extend your shiny app with other framework like react, vue, tailwind… since shinyapp only requires just iframe and javascript code.
  4. You don’t need to consider deploy. (Github will do that)
Cons
  1. webR is in really really really earlier stage. so it doesn’t have much references, resources to refer.
  2. wasm shiny application requires time to initiate webR and shiny in chrome (this is critical, it takes so much time sometime randomly)
  3. (as Leemput already mentioned) why shiny? we can just use common web framework as UI and input, then utilize just webR not shiny.
  4. Heavier work (like file I/O) doesn’t supports yet in wasm shiny.

Future work?

I think some can be improved.

  1. use javascript as separate file (in qmd’s module script) : so quarto only requires iframe and button
  2. use app.R via repo not URL
  3. render quarto page to /docs not root: with this, quarto blog can use wasm shiny application well.
  4. research about what can be done or not via wasm shiny application. )I checked file upload / download can’t)

Other ways to use webR

You may note that, there are other options to build shiny webR using golem framework. (I’ll not brief them)

There is quarto template use webR (not supports shiny yet)

Thanks to community


If you have question or some ideas. Let’s talk!



Build serverless shiny application via Github page was first posted on August 31, 2023 at 7:10 pm.
To leave a comment for the author, please follow the link and comment on their blog: R-posts.com.

R-bloggers.com offers daily e-mail updates about R news and tutorials about learning R and many other topics. Click here if you're looking to post or find an R/data-science job.
Want to share your content on R-bloggers? click here if you have a blog, or here if you don't.
Exit mobile version