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.
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.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();
// 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.
Line 3–4
add header toenable-thread.js
: github page has some permissions-policy (CORB / COOP / COEP) that blocks resource from other source page. so add this to enable it.Line 7
add “=html” to HTML code in quarto (not just show it)First TODO
set app.R code via URL: I tried to include in repo and call it likerepo/app.R
, but it didn’t work. so uploadapp.R
in your repo and call it with raw file URLLast 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
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
- You can deploy simple shinyapp with static page (github page)
- You don’t need to consider cost / scale / performance since wasm uses client (User) ‘s PC.
- You can extend your shiny app with other framework like react, vue, tailwind… since shinyapp only requires just iframe and javascript code.
- You don’t need to consider deploy. (Github will do that)
Cons
- webR is in really really really earlier stage. so it doesn’t have much references, resources to refer.
- wasm shiny application requires time to initiate webR and shiny in chrome (this is critical, it takes so much time sometime randomly)
- (as Leemput already mentioned) why shiny? we can just use common web framework as UI and input, then utilize just webR not shiny.
- Heavier work (like file I/O) doesn’t supports yet in wasm shiny.
Future work?
I think some can be improved.
- use javascript as separate file (in qmd’s module script) : so quarto only requires iframe and button
- use app.R via repo not URL
- render quarto page to /docs not root: with this, quarto blog can use wasm shiny application well.
- 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)
- https://github.com/DivadNojnarg/golemWebR
- https://github.com/RinteRface/webR4Shiny
- https://golem-webr.rinterface.com/
There is quarto template use webR (not supports shiny yet)
Thanks to community
- George Stagg for
httpuv-serviceworker.js
- Joseph rocca for
enable-thread.js
- Veerle van Leemput for kind introduction and help
If you have question or some ideas. Let’s talk!