Skip to content

Remote R development via SSH

Iakov Davydov edited this page Nov 8, 2024 · 4 revisions

If you are running neovim with R.nvim via SSH it is possible to view plots and tables on the local machine.

Ports

The idea behind this approach is that you use SSH port forwarding to be able to conned to HTTP servers for graphics (httpgd). Since you are using SSH port forwarding this will work even in the presence of a firewall.

First, choose a range of ports you would like to use, then add the following lines to your local .ssh/config:

Host remotehostname
  LocalForward 127.0.0.1:7030 127.0.0.1:7030
  LocalForward 127.0.0.1:7031 127.0.0.1:7031
  LocalForward 127.0.0.1:7032 127.0.0.1:7031
  LocalForward 127.0.0.1:7033 127.0.0.1:7033
  LocalForward 127.0.0.1:7034 127.0.0.1:7034
  LocalForward 127.0.0.1:7035 127.0.0.1:7035
  LocalForward 127.0.0.1:7036 127.0.0.1:7036
  LocalForward 127.0.0.1:7037 127.0.0.1:7037
  LocalForward 127.0.0.1:7038 127.0.0.1:7038
  LocalForward 127.0.0.1:7039 127.0.0.1:7039
  # silence port connection messages
  LogLevel QUIET

Unfortunately, SSH port forwarding doesn't support port ranges, so you have to have one line per port.

Remote browser calls

You want to make sure that when remote R tries to open a web-browser a local web browser window opens. To achieve this you can use opener. Once installed, make sure that opener actually works by calling browserURL("https://google.com") in the remote R session. If all works well, this should a browser window on your local host.

Warning: Depending on the configuration other users using the same remote host could open URLs on your local host. Use with caution.

Graphics device

Install httpgd and withr.

When you have opener and port forwarding enabled now you should make sure that httpgd can use ports from the correct range.

Put this into your ~/.Rprofile:

# return a free port from the specified range
.free_port <- function(host = "127.0.0.1", ports = 7030:7039) {
  withr::with_options(
    # we do not want to actually open a browser
    list(browser = identity),
    {
      for (p in ports) {
        if (servr:::port_available(p, host)) {
          return(p)
        }
      }
    }
  )
  stop("no free ports found")
}

options(
  device = function(...) {
    httpgd::hgd(..., port = .free_port())
  }
)

If you want to have a shortcut (<LocalLeader>gd) to open a httpgd page make sure to add the following to your neovim config:

{
    "R-nvim/R.nvim",
    ...
    keys = {
      ...
      {
        "<LocalLeader>gd",
        "<cmd>lua require('r.send').cmd('tryCatch(httpgd::hgd_browse(),error=function(e) {httpgd::hgd(port=.free_port());httpgd::hgd_browse()})')<CR>",
        desc = "httpgd",
      },
    ...
}

Opening local HTML content

If you are using gt::gt() or other similar tools which rely on showing HTML content in R you can use servr to serve you the content. The tricky part is that you have to translate URLs from local filesystem to servr paths.

Here's one approach.

Warning: This assumes that only you can connect to local ports on the remote machine you are working on. If you are using a shared host you might need to use additional security measures such at HTTP authentication.

This should go into your ~/.Rprofile.

# open a real URL using opener xdg-open
.browse_real_URL <- function(url) {
  withr::with_options(
    list(browser = file.path(Sys.getenv("HOME"), "bin/xdg-open")),
    browseURL(url)
  )
}

# simplified check for URL
.is_url <- function(string) {
  pattern <- "https?://[^ /$.?#].[^\\s]*"
  grepl(pattern, string)
}

# set a browser function to rewrite URLs
options(
  browser = function(url) {
    if (.is_url(url)) {
      # when getting a real URL, just open the browser
      .browse_real_URL(url)
    } else {
      # we are starting two servr's web serveres:
      # one for HTML files in the current directory

      # one could start servr in the root `/` directory, but this is
      # even more dangerous from the security perspective
      if (!exists(".curdir_srv")) {
        .curdir <<- getwd()
        withr::with_options(
          list(browser = identity),
          {
            .curdir_srv <<- servr::httd(.curdir, port = .free_port())
          }
        )
      }
      # one for HTML files in the temporary directory
      if (!exists(".tmpdir_srv")) {
        withr::with_options(
          list(browser = identity),
          {
            .tmpdir_srv <<- servr::httd(tempdir(), port = .free_port())
          }
        )
      }
      # is the file in the temporary directory?
      if (startsWith(url, tempdir())) {
        # rewrite URL and redirect to the server for the temporary directory
        .browse_real_URL(
          paste0(
            .tmpdir_srv$url,
            substring(url, nchar(tempdir()) + 1)
          )
        )
      # is the file in the local directory?
      } else if (startsWith(url, .curdir)) {
        # rewrite URL and redirect to the server for the local directory
        .browse_real_URL(
          paste0(
            .curdir_srv$url,
            substring(url, nchar(.curdir) + 1)
          )
        )
      } else if (startsWith(url, "/")) {
        # do not know what to do with other paths
        cat(url, "\n")
      } else {
        # otherwise it's a file relative to the current directory
        .browse_real_URL(
          paste0(
            .curdir_srv$url,
            "/",
            url
          )
        )
      }
    }
  }
)

Opening knitted files

R.nvim will automatically try to open rendered .rmd files if open_html is set to open. Alternatively you can call manually:

browseURL("report.html")