An Introduction to Building Web Applications and Services Using R

Yihui Xie @ Yale School of Public Health

2024-02-13

1. Motivating examples

1.1 A new way of working with R scripts

This example requires the servr package and the latest development version of xfun, which you may install via:

install.packages('servr')
install.packages('xfun', repos = 'https://yihui.r-universe.dev')

Restart R after installation.

Make sure you have some R scripts under your current working directory. Then run:

servr::httr()


1.2 Talk to a web page and draw a barplot

The code is a little complicated here and I don’t mean to make you fully understand it, but just want to show you a possibility.

You say “plot something”, and the words will be sent to R. We use R to split words into letters, and draw a barplot to show the frequencies of letters. If you say “plot nothing”, the page will be emptied.

library(httpuv)
p = if (servr:::port_available(43210)) 43210 else randomPort()
s = startServer(
  host = '127.0.0.1',
  port = p,
  app  = list(
    call = function(req) {
      list(
        status = 200,
        headers = list('Content-Type' = 'text/html'),
        body = "
        <html>
        <head>
          <title>Test speech recognition</title>
          <script src='https://cdnjs.cloudflare.com/ajax/libs/annyang/2.6.0/annyang.min.js'></script>
        </head>
        <body>
          <p><img id='barplot' /></p>
          <p id='speech'></p>
          <script>
          const ws = new WebSocket(location.href.replace(/^http/, 'ws').replace(/\\/?$/, '/websocket/'));
          ws.onmessage = e => {
            document.getElementById('barplot').src = e.data;
          };
          annyang.addCommands({'plot *plot': plot => {
            document.getElementById('speech').innerText += ('... ' + plot);
            ws.send(plot);
          }});
          annyang.start();
          </script>
        </body>
        </html>
        "
      )
    },
    onWSOpen = function(ws) {
      d = character()
      ws$onMessage(function(binary, message) {
        if (message == 'nothing') return(ws$send(''))
        
        m = toupper(unlist(strsplit(message, '')))
        m = m[m %in% LETTERS]
        d <<- c(d, m)
        freqs = table(factor(d, levels = LETTERS))
        f = tempfile(fileext = '.png'); on.exit(file.remove(f))
        png(f, width = 800)
        barplot(
          freqs, main = message, ylim = c(0, ceiling(max(freqs)/5) * 5),
          col = palette.colors(26, 'Alphabet')
        )
        dev.off()
        
        ws$send(xfun::base64_uri(f))
      })
    }
  )
)
browseURL(sprintf('http://127.0.0.1:%d', p))
s$stop()

2. How do the examples work under the hood?

The answer lies in the httpuv package (it’s not the only option for creating web applications with R; more on this later).


2.1 A minimal example: output a random number

library(httpuv)
p = if (servr:::port_available(43210)) 43210 else randomPort()
s = startServer(
  host = '127.0.0.1', port = p, app = list(
    call = function(req) {
      list(status = 200, body = paste('Hello', rnorm(1)))
    }
  )
)
browseURL(sprintf('http://127.0.0.1:%d', p))
# stop the server when you are done
s$stop()

2.2 An example of building a web API

library(httpuv)
p = if (servr:::port_available(43210)) 43210 else randomPort()
s = startServer(
  host = '127.0.0.1', port = p, app = list(
    call = function(req) {
      params = sub('^[?]', '', req$QUERY_STRING)
      params = gsub('&', ',', params)
      params = eval(parse(text = c('list(', params, ')')))
      if (is.null(params$n)) params$n = 30
      res = do.call(rnorm, params)
      list(status = 200, body = jsonlite::toJSON(res))
    }
  )
)
browseURL(sprintf('http://127.0.0.1:%d?n=10&mean=1', p))
s$stop()

3. WebSocket: two-way communication between server and browser

Draw a QQ plot in R with half of the browser window width:

library(httpuv)
p = if (servr:::port_available(43210)) 43210 else randomPort()
s = startServer(
  host = '127.0.0.1', port = p, app  = list(
    call = function(req) {
      list(
        status = 200,
        headers = list('Content-Type' = 'text/html'),
        body = "
        <html>
        <head>
          <title>Test websocket</title>
        </head>
        <body>
          <p><img id='qqplot' /></p>
          <button id='generate'>New QQ plot</button>
          <script>
          const ws = new WebSocket(location.href.replace(/^http/, 'ws').replace(/\\/?$/, '/websocket/'));
          ws.onmessage = e => {
            console.log('Data sent from R: ' + e.data.substr(0, 200));
            document.getElementById('qqplot').src = e.data;
          };
          document.getElementById('generate').onclick = e => {
            ws.send(window.innerWidth);
          };
          </script>
        </body>
        </html>
        "
      )
    },
    onWSOpen = function(ws) {
      ws$onMessage(function(binary, message) {
        cat('The window width from JavaScript is', message, 'px.\n')
        
        # draw a plot with half of the width of the browser window
        f = tempfile(fileext = '.png'); on.exit(file.remove(f))
        png(f, width = as.numeric(message)/2)
        x = rnorm(30)
        qqnorm(x, xlim = c(-3, 3), ylim = c(-3, 3))
        qqline(x)
        dev.off()
        
        ws$send(xfun::base64_uri(f))
      })
    }
  )
)
browseURL(sprintf('http://127.0.0.1:%d', p))
s$stop()

Again, the code may look complicated in your eyes, but essentially the idea is the following:

3.1 On the JavaScript side

ws = new WebSocket();

// Message received from R
ws.onmessage = function(event) {
  event.data;
};

// Message to R
ws.send('...');

3.2 On the R side

ws$onMessage(function(binary, message) {
  # `message` is from browser via JavaScript
  
  # After processing the message, send another message to browser
  ws$send('...')
})

4. Other approaches

4.1 You can also use Rserve

I won’t cover this package in this talk (actually I haven’t used it much), but just FYI: https://cran.r-project.org/package=Rserve


4.2 Or using base R only (for hackers)

In theory, you can register your app handler in tools:::.httpd.handlers.env but this is just for fun (::: is a danger sign). Note that base R’s HTTP server doesn’t support WebSocket.

new_app = function(name, handler) {
  port = tools::startDynamicHelp(NA)
  url  = sprintf('http://127.0.0.1:%d/custom/%s', port, name)
  assign(name, handler, envir = tools:::.httpd.handlers.env)
  browseURL(url)
}

new_app('rnorm', handler = function(path, ...) {
  list(payload = sprintf('<h1>N(0, 1) = %f</h1>', rnorm(1)))
})

If you know CSS (to style web elements) and JavaScript (to program web pages):

new_app('rnorm', handler = function(path, ...) {
  x = rnorm(1)
  color = ifelse(x > 0, 'blue', 'red')
  list(payload = paste0(
    sprintf('<h1>N(0, 1) = <i style="color: %s;">%f</i></h1>', color, x),
    '<script>setTimeout(() => location.reload(), 1000);</script>'
  ))
})

Thanks!