Skip to content

Proxy WebSocket communication through the main thread #562

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 20 commits into from
Jul 28, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ jobs:
run: cd src/docs && make
shell: bash
- name: Publish to npm
if: "!contains(github.ref_name, 'main')"
if: "!contains(github.ref_name, 'main') && !github.event_name == 'workflow_dispatch'"
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
run: cd src && make publish
Expand Down
14 changes: 14 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,19 @@
# webR (development version)

## New features

* Proxy WebSocket communication through the main thread when using the `SharedArrayBuffer` communication channel (#562).

* Added support for `webr::eval_js(..., await = TRUE)`.

* Added a SSL cacert bundle to the Emscripten VFS (#562).

* Added support mechanism for running `curl` and `httr2` using a WebSocket proxy + SOCKS tunnel running outside the browser (#562).

## Breaking changes

* Removed the service worker communication channel. This channel has been deprecated for a while now, and was never chosen in `Automatic` mode (#562).

# webR 0.5.4

## New features
Expand Down
1 change: 1 addition & 0 deletions R/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ MAIN_LDFLAGS += -s DECLARE_ASM_MODULE_EXPORTS=0
MAIN_LDFLAGS += -s ERROR_ON_UNDEFINED_SYMBOLS=0
MAIN_LDFLAGS += -s EXPORTED_RUNTIME_METHODS=$(EXPORTED_RUNTIME_METHODS)
MAIN_LDFLAGS += -s FETCH=1
MAIN_LDFLAGS += -s WEBSOCKET_URL=wss://
MAIN_LDFLAGS += -lworkerfs.js -lnodefs.js -lidbfs.js

MAIN_LDFLAGS_ADD := --embed-file "$(R_WASM_TMP)/lib@/usr/lib"
Expand Down
2 changes: 1 addition & 1 deletion flake.nix
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
# cd src; prefetch-npm-deps package-lock.json
srcNpmDeps = pkgs.fetchNpmDeps {
src = "${self}/src";
hash = "sha256-0H2tXEN87yYS9Qn0uoe2uubrDQHQTjc6OR6b3OA6YHE=";
hash = "sha256-9GxLYvfilstNBgdzdbbuGCu4yuoSL7Axj6a30V3vahc=";
};

inherit system;
Expand Down
2 changes: 1 addition & 1 deletion libs/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ default: $(DEFAULT_WASM_LIBS) $(DEFAULT_WASM_BINS_INST)
.DEFAULT_GOAL := default

.PHONY: all
all: default $(OPTIONAL_WASM_LIBS) $(OPTIONAL_WASM_BINS_INST) fonts
all: default $(OPTIONAL_WASM_LIBS) $(OPTIONAL_WASM_BINS_INST) fonts cacert

$(EM_PKG_CONFIG_PATH)/%.pc: recipes/**/%.pc
mkdir -p $(EM_PKG_CONFIG_PATH)
Expand Down
6 changes: 6 additions & 0 deletions libs/recipes/cacert/rules.mk
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
.PHONY: cacert
cacert: $(WASM)/etc/ssl/cert.pem

$(WASM)/etc/ssl/cert.pem:
mkdir -p "$(WASM)/etc/ssl"
curl --output "$(WASM)/etc/ssl/cert.pem" https://curl.se/ca/cacert.pem
1 change: 1 addition & 0 deletions libs/recipes/cacert/targets.mk
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
WASM_LAZY_VFS += -f "$(WASM)/etc/ssl@/etc/ssl"
3 changes: 2 additions & 1 deletion libs/recipes/curl/rules.mk
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,15 @@ $(CURL_TARBALL):
mkdir -p $(DOWNLOAD)
wget $(CURL_URL) -O $@

$(CURL_WASM_LIB): $(CURL_TARBALL) $(NGHTTP2_WASM_LIB) $(OPENSSL_WASM_LIB)
$(CURL_WASM_LIB): $(CURL_TARBALL) $(NGHTTP2_WASM_LIB) $(OPENSSL_WASM_LIB) $(WASM)/etc/ssl/cert.pem
mkdir -p $(BUILD)/curl-$(CURL_VERSION)/build
tar -C $(BUILD) -xf $(CURL_TARBALL)
cd $(BUILD)/curl-$(CURL_VERSION)/build && \
PKG_CONFIG="pkg-config --static" emconfigure ../configure \
--enable-shared=no \
--enable-static=yes \
--prefix=$(WASM) \
--with-ca-bundle=/etc/ssl/cert.pem \
--with-openssl \
--with-zlib \
--with-nghttp2 \
Expand Down
21 changes: 17 additions & 4 deletions packages/webr/R/eval.R
Original file line number Diff line number Diff line change
Expand Up @@ -120,8 +120,12 @@ eval_r <- function(
}

# Ensure incomplete lines are flushed to output vector
if (isIncomplete(out$stdout)) cat(fill = TRUE, file = out$stdout)
if (isIncomplete(out$stderr)) cat(fill = TRUE, file = out$stderr)
if (isIncomplete(out$stdout)) {
cat(fill = TRUE, file = out$stdout)
}
if (isIncomplete(out$stderr)) {
cat(fill = TRUE, file = out$stderr)
}

# Output vector out$vec expands exponentially, return only the valid subset
list(result = res, output = utils::head(out$vec, out$n))
Expand All @@ -139,11 +143,19 @@ eval_r <- function(
#' The JavaScript code is evaluated using `emscripten_run_script_int` from the
#' Emscripten C API. In the event of a JavaScript exception an R error condition
#' will be raised with the exception message.
#'
#' If a JavaScript promise is returned, set `await = TRUE` to wait for the
#' promise to resolve. The result of the promise is returned, or an error is
#' raised if the promise is rejected.
#'
#' When `await = TRUE`, the given JavaScript code is run on the main thread.
#' Otherwise, the JavaScript code is run in the webR web worker context.
#'
#' This is an experimental function that may undergo a breaking changes in the
#' future.
#'
#' @param code The JavaScript code to evaluate.
#' @param await Wait for promises to resolve, defaults to `FALSE`.
#'
#' @return Result of evaluating the JavaScript code, returned as an R object.
#' @examples
Expand All @@ -152,11 +164,12 @@ eval_r <- function(
#' eval_js("Math.sin(1)")
#' eval_js("true")
#' eval_js("undefined")
#' eval_js("Promise.resolve(123)")
#' eval_js("(new Date()).toUTCString()")
#' eval_js("new RList({ foo: 123, bar: 456, baz: ['a', 'b', 'c']})")
#' }
#' @export
#' @useDynLib webr, .registration = TRUE
eval_js <- function(code) {
.Call(ffi_eval_js, code)
eval_js <- function(code, await = FALSE) {
.Call(ffi_eval_js, code, await)
}
12 changes: 11 additions & 1 deletion packages/webr/man/eval_js.Rd

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions packages/webr/src/init.c
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#include <Rinternals.h>
#include <R_ext/Rdynload.h>

extern SEXP ffi_eval_js(SEXP);
extern SEXP ffi_eval_js(SEXP, SEXP);
extern SEXP ffi_obj_address(SEXP);
extern SEXP ffi_new_output_connections(void);
extern SEXP ffi_dev_canvas(SEXP, SEXP, SEXP, SEXP, SEXP, SEXP);
Expand All @@ -17,7 +17,7 @@ extern SEXP ffi_unmount(SEXP);

static
const R_CallMethodDef CallEntries[] = {
{ "ffi_eval_js", (DL_FUNC) &ffi_eval_js, 1},
{ "ffi_eval_js", (DL_FUNC) &ffi_eval_js, 2},
{ "ffi_obj_address", (DL_FUNC) &ffi_obj_address, 1},
{ "ffi_new_output_connections", (DL_FUNC) &ffi_new_output_connections, 0},
{ "ffi_dev_canvas", (DL_FUNC) &ffi_dev_canvas, 6},
Expand Down
14 changes: 11 additions & 3 deletions packages/webr/src/webr.c
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
#endif

#define BUFSIZE 64
SEXP ffi_eval_js(SEXP code) {
SEXP ffi_eval_js(SEXP code, SEXP await) {
#ifdef __EMSCRIPTEN__
if (!Rf_isString(code) || LENGTH(code) != 1) {
Rf_error("`code` must be a character string.");
Expand All @@ -19,9 +19,17 @@ SEXP ffi_eval_js(SEXP code) {
Rf_error("`code` must not be `NA`.");
}

const char *eval_template = "globalThis.Module.webr.evalJs(%p)";
if (!Rf_isLogical(await) || LENGTH(await) != 1) {
Rf_error("`await` must be a logical.");
}

if (LOGICAL(await)[0] == NA_LOGICAL){
Rf_error("`await` can't be `NA`.");
}

const char *eval_template = "globalThis.Module.webr.evalJs(%p, %d)";
char eval_script[BUFSIZE];
snprintf(eval_script, BUFSIZE, eval_template, R_CHAR(STRING_ELT(code, 0)));
snprintf(eval_script, BUFSIZE, eval_template, R_CHAR(STRING_ELT(code, 0)), LOGICAL(await)[0]);
return (SEXP) emscripten_run_script_int(eval_script);
#else
Rf_error("Function must be running under Emscripten.");
Expand Down
2 changes: 1 addition & 1 deletion packages/webr/src/webr.h
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@

#include <Rinternals.h>

SEXP ffi_eval_js(SEXP code);
SEXP ffi_eval_js(SEXP code, SEXP await);
SEXP ffi_safe_eval(SEXP call, SEXP env);

#endif
1 change: 1 addition & 0 deletions src/docs/_quarto.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ website:
- communication.qmd
- evaluating.qmd
- plotting.qmd
- networking.qmd
- section: Working with R Objects
contents:
- objects.qmd
Expand Down
134 changes: 134 additions & 0 deletions src/docs/networking.qmd
Original file line number Diff line number Diff line change
@@ -0,0 +1,134 @@
---
title: "Networking"
format: html
toc: true
---

## Networking under WebAssembly

WebAssembly runs in a sandbox environment and doesn't support direct network socket connections for security reasons. This means that traditional R networking and file downloading approaches need some extra work to be used with webR.

Luckily, webR provides two solutions to enable network connectivity:

1. **Basic downloads** using base R's `download.file()`.
2. **Advanced networking** using a WebSocket proxy, for packages like curl and httr2.

## Basic downloads with `download.file()`

For simple file downloads, webR patches the `download.file()` function to work inside a web browser. The patch works by intercepting requests and handling them using JavaScript APIs. This method is easy to use and in many cases is sufficient.

### Limitations

The approach works well for basic HTTP(S) downloads, but it does come with some limitations imposed by the security of the browser environment:

- The server hosting the file must supply CORS ([Cross-Origin Resource Sharing](https://developer.mozilla.org/en-US/docs/Web/HTTP/Guides/CORS)) headers to permit cross-origin requests.

- Only simple HTTP/HTTPS `GET` requests are supported. You cannot use another HTTP method, set headers, or use any of the advanced options available in curl/httr2.

Many web APIs and servers don't supply CORS headers with their content, which is why you might need the WebSocket proxy method below.

### Simple Download Example

```r
# Download a file directly, no other setup required
download.file("https://raw.githubusercontent.com/tidyverse/ggplot2/refs/heads/main/data-raw/diamonds.csv", "diamonds.csv")
```

## Advanced networking with a WebSocket proxy

For more complex networking, webR can proxy network connections through WebSockets. This enables the curl and httr2 packages, along with other TCP connections.

The functionality is provided by Emscripten, which emulates the underlying POSIX Socket APIs with communication over WebSockets. You can read more about this kind of networking support in the [Emscripten documentation](https://emscripten.org/docs/porting/networking.html#emulated-posix-tcp-sockets-over-websockets).


The main drawback of this approach is that it requires a special proxy server (outside of webR) which can route all our traffic over a websocket. Below it is explained how to setup such a proxy server.

### Using curl and httr2 in webR

If you have access to a SOCKS proxy with support for WebSocket-to-TCP connections, you can use standard proxy settings directly. Emscripten will convert the network connections into WebSocket connections automatically.

For curl, set the `proxy` option:

```r
library(curl)

# Set up the proxy
handle <- new_handle(proxy = "socks5h://socks.example.com:443")

# Make requests
response <- curl_fetch_memory("https://hb.cran.dev/get", handle = handle)
```

For httr2, use the `req_proxy()` function:

```r
library(httr2)
library(jsonlite)

# Create a request with proxy
response <- request("https://hb.cran.dev/get") |>
req_proxy("socks5h://socks.example.com:443") |>
req_perform()

# Extract the response
data <- response |> resp_body_json()
```

### Running a local WebSocket proxy

If you don't have access to a SOCKS proxy with support for WebSocket-to-TCP connections, you may be able to start one locally on your device.

You'll need to set up two components outside of webR:

1. **A WebSocket-to-TCP proxy** - We'll use [`websockify`](https://github.com/novnc/websockify).
2. **A SOCKS proxy** - We'll connect to an SSH server and use port forwarding to create a SOCKS proxy.

#### Step 1: Install the WebSocket-to-TCP proxy

Install `websockify` on your system. This can be done via `pip`, other Python environment tools, or your system's package manager.

```bash
# Using pip, with a Python virtual environment if needed
source .venv/bin/activate
pip install websockify
```

```bash
# You can start websockify directly if using uv
uvx websockify [...]
```

#### Step 2: Setup a SOCKS proxy

On Unix systems, you can usually start a SOCKS proxy with the following OpenSSH command:

```bash
# Replace `localhost` if you're not using a local SSH server
ssh -N -D 8581 localhost
```

On Windows systems, a local SOCKS proxy can be started with [PuTTY](https://www.chiark.greenend.org.uk/~sgtatham/putty/) in the [Tunnels panel](https://the.earth.li/~sgtatham/putty/0.83/htmldoc/Chapter4.html#config-ssh-portfwd).

**What this does**: Creates a SOCKS proxy on port 8581 that routes traffic through the SSH connection.

#### Step 3: Start websockify

In a new terminal, start `websockify` to bridge WebSocket connections to your SOCKS proxy:

```bash
websockify localhost:8580 localhost:8581
```

**What this does**: Creates a WebSocket server on port 8580 that forwards connections to your SOCKS proxy on port 8581.

#### Step 4: Setup webR

Since we have started `websockify` without support for SSL, we must configure webR to connect to WebSockets over the `ws://` protocol.

At the top of your script, before trying to connect to a HTTPS URL, first execute:

```r
webr::eval_js("SOCKFS.websocketArgs.url = 'ws://'")
```

Finally, once setup is complete, configure curl or httr2 in the previous section using `socks5h://localhost:8050` as your proxy URL.
5 changes: 1 addition & 4 deletions src/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ function build(input: string, output: string, platform: esbuild.Platform, minify
assetNames: 'assets/[name]-[hash]',
bundle: true,
entryPoints: [input],
external: ['worker_threads', 'path', 'fs'],
external: ['worker_threads', 'path', 'fs', 'ws'],
loader: {
'.jpg': 'file',
'.png': 'file',
Expand All @@ -46,13 +46,10 @@ function build(input: string, output: string, platform: esbuild.Platform, minify
const outputs = {
browser: [
build('repl/App.tsx', '../dist/repl.mjs', 'browser', prod),
build('webR/chan/serviceworker.ts', '../dist/webr-serviceworker.js', 'browser', false),
build('webR/webr-worker.ts', '../dist/webr-worker.js', 'node', true),
build('webR/webr-main.ts', '../dist/webr.mjs', 'neutral', prod),
],
npm: [
build('webR/chan/serviceworker.ts', './dist/webr-serviceworker.mjs', 'neutral', false),
build('webR/chan/serviceworker.ts', './dist/webr-serviceworker.js', 'browser', false),
build('webR/webr-worker.ts', './dist/webr-worker.js', 'node', true),
build('webR/webr-main.ts', './dist/webr.cjs', 'node', prod),
build('webR/webr-main.ts', './dist/webr.mjs', 'neutral', prod),
Expand Down
Loading