Developing React Applications in RStudio Workbench
Introduction
RStudio Workbench provides a development environment for R, Python, and many other languages. When developing a performant web application you may progress from Shiny towards tools like Plumber. This allows you to continue development of the true application code, modelling, data processing in the language you already know, R, while providing an interface to those processes suitable for embedding in larger web-based applications.
As a Shiny developer, one popular front-end library you might already be familiar with is React. React is used in many popular {htmlwidgets} such as {reactable} and anything built with {reactR}.
React is a “A JavaScript library for building user interfaces”. It provides the coupling between your data, application state, and the HTML-based user interface. An optional extension of JavaScript called JSX allows efficient definition of React based user interface components. Another optional extension of JavaScript called TypeScript enables strict typing of your code, potentially improving the quality.
This short article covers some technical hurdles to make use of the standard Create React App workflow, this is compatible with Typescript, Redux, and other extensions.
Initial Setup
We are using RStudio Workbench for our development, and VS Code Sessions within Workbench as our IDE. Some extensions will be installed by default, a few of our enabled extensions include:
- Error Lens, for better inline error notifications
- Git Graph, for reviewing git history
- GitLens, for additional inline information about git history
- RStudio Workbench, for detection of running servers
The last extension is the most important and is installed when following the RStudio Workbench installation guide.
We will assume a recent version of Node.js is installed, check with nodejs --version
. We are using
version 16.13.2. Open a VS Code Session in RStudio Workbench and create a new project:
npx create-react-app my-app --template typescript
The issues
Following the getting started instructions, we will enter the new project and start the development server:
cd my-app
npm start
If port 3000 is already in use, you will be prompted to use another port, allow this. Now we will
use the RStudio Workbench extension to find our development server, in our case, 0.0.0.0:3001
:
When you open one of these links you will find a blank page instead of the usual spinning React logo. Inspecting the Console will identify several issues. These all stem from the URL provided by the RStudio Workbench extension. We can easily resolve all of these problems.
Issue 1 - Incorrect application root
The example template (public/index.html
) uses a variable PUBLIC_URL to enable development within
different environments. The favicon has an href of %PUBLIC_URL%/favicon.ico
.
Our application is now privately available at
https://rstudio.jumpingrivers.cloud/workbench/s/ecb2d3c9ab5a71bf18071/p/fc2c1fd4/
for us to use
and test while developing it. Click on a server in the RStudio Workbench extension to open your
own link.
Create a file called .env.development
in the root of your project with the following contents:
PUBLIC_URL=/workbench/s/ecb2d3c9ab5a71bf18071/p/fc2c1fd4
If npm start
is still running, stop it now and restart it. Refresh your application now as well.
The session and process IDs will usually remain the same so you will have another blank page, but
fewer console errors.
Issue 2 - Incorrect routes in Express dev server
The files you are expecting are now missing because the development server uses the new PUBLIC_URL to serve content, but RStudio Workbench is removing the subdirectories when it maps back to 0.0.0.0:3000.
We can set up a proxy to server content on both “/workbench/s/ecb2d3c9ab5a71bf18071/p/fc2c1fd4” and “/”. Create a file “./src/setupProxy.js” with the following content:
module.exports = function (app) {
app.use((req, _, next) => {
if (!req.url.startsWith(process.env.PUBLIC_URL))
req.url = process.env.PUBLIC_URL + req.url;
next();
});
};
When you now restart the dev server npm start
and refresh the browser you will finally see a
spinning React logo.
Two errors in the console remain.
Issue 3 - Invalid manifest
By definition, a web application manifest will not be requested with any authentication cookies. This is a very easy fix.
In “public/index.html” add crossorigin="use-credentials"
, e.g.
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
becomes:
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials" />
Refresh your application to see the changes immediately. In some cases you may require
authentication in produciton, but if not then you should only enable it in development mode. We can
use the already included HtmlWebpackPlugin
to conditionally include our changes:
<% if (process.env.NODE_ENV === 'development') { %>
<!-- enable authentication in dev mode only -->
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" crossorigin="use-credentials" />
<% } else { %>
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
<% } %>
Note that if you are running your production application behind a protected endpoint, such as when using RStudio Connect, you may remove the conditional statement and include credentials in all cases.
Issue 4 - WebSocket connections
Finally, you may have noticed that auto-reload is not enabled and there are network errors every ~5 seconds.
We can fix this by intercepting all WebSocket connections made from our web page. Add a script tag to the head of “public/index.html”. As with the authenticated manifest, we can embed this script only when in development mode.
<% if (process.env.NODE_ENV === 'development') { %>
<script>
const WebSocketProxy = new Proxy(window.WebSocket, {
construct(target, args) {
console.log("Proxying WebSocket connection", ...args);
let newUrl = "wss://" + window.location.host + "%PUBLIC_URL%/ws";
const ws = new target(newUrl);
// Configurable hooks
ws.hooks = {
beforeSend: () => null,
beforeReceive: () => null
};
// Intercept send
const sendProxy = new Proxy(ws.send, {
apply(target, thisArg, args) {
if (ws.hooks.beforeSend(args) === false) {
return;
}
return target.apply(thisArg, args);
}
});
ws.send = sendProxy;
// Intercept events
const addEventListenerProxy = new Proxy(ws.addEventListener, {
apply(target, thisArg, args) {
if (args[0] === "message" && ws.hooks.beforeReceive(args) === false) {
return;
}
return target.apply(thisArg, args);
}
});
ws.addEventListener = addEventListenerProxy;
Object.defineProperty(ws, "onmessage", {
set(func) {
const onmessage = function onMessageProxy(event) {
if (ws.hooks.beforeReceive(event) === false) {
return;
}
func.call(this, event);
};
return addEventListenerProxy.apply(this, [
"message",
onmessage,
false
]);
}
});
// Save reference
window._websockets = window._websockets || [];
window._websockets.push(ws);
return ws;
}
});
window.WebSocket = WebSocketProxy;
</script>
<% } %>
Refresh your applications and wait to confirm that there are no WebSocket connection errors any more.
References
- Intercept WebSockets: https://gist.github.com/Checksum/27867c20fa371014cf2a93eafb7e0204
- Change PUBLIC_URL: https://stackoverflow.com/a/58508562
- React development proxies: https://create-react-app.dev/docs/proxying-api-requests-in-development/
- Authenticated manifest.json: https://stackoverflow.com/a/57184506
- Conditional index.html: https://betterprogramming.pub/how-to-conditionally-change-index-html-in-react-de090b51fed3