Skip to content

Commit

Permalink
Enable webr::eval_js() to return other types of R object (#483)
Browse files Browse the repository at this point in the history
* Return SEXP object in `webr::eval_js()`

* Map JS `undefined` to R's NULL object

* When constructing with RList, create lists deeply

* Update NEWS.md

* Update webr::eval_js() documentation

* Update examples

* Fix OffScreenCanvas mocking with webr::eval_js()

* Better detect "simple" objects for list conversion

* Make RWorker R objects available in WorkerGlobalScope

Fixes running webr::eval_js() under Node, by explicitly setting these
objects to be available in the global scope.

With these names now explicitly defined, we can also enable minification
of the worker script.

* Update unit tests for changes to webr::eval_js()
  • Loading branch information
georgestagg authored Sep 19, 2024
1 parent cf27907 commit be0e296
Show file tree
Hide file tree
Showing 11 changed files with 150 additions and 32 deletions.
6 changes: 6 additions & 0 deletions NEWS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
# webR (development version)

## Breaking changes

* The `webr::eval_js()` function can now return other types of R object, not just scalar integers. Returned JavaScript objects are converted to R objects using the `RObject` generic constructor, and specific R object types can be returned by invoking the R object constructor directly in the evaluated JavaScript.

* When explicitly creating a list using the `RList` constructor, nested JavaScript objects at a deeper level are also converted into R list objects. This does not affect the generic `RObject` constructor, as the default is for JavaScript objects to map to R `data.frame` objects using the `RDataFrame` constructor.

# webR 0.4.2

## New features
Expand Down
20 changes: 14 additions & 6 deletions packages/webr/R/eval.R
Original file line number Diff line number Diff line change
Expand Up @@ -128,21 +128,29 @@ eval_r <- function(expr,
#' Evaluate JavaScript code
#'
#' @description
#' This function evaluates the given character string as JavaScript code. The
#' result is returned as an integer.
#' This function evaluates the given character string as JavaScript code.
#' Returned JavaScript objects are converted to R objects using the `RObject`
#' generic constructor, and specific R object types can be returned by invoking
#' the R object constructor directly in the evaluated JavaScript.
#'
#' @details
#' 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.
#'
#' This is an experimental function that may undergo a breaking change in the
#' future so as to support different return types.
#' This is an experimental function that may undergo a breaking changes in the
#' future.
#'
#' @param code The JavaScript code to evaluate.
#'
#' @return Integer result of evaluating the code.
#'
#' @return Result of evaluating the JavaScript code, returned as an R object.
#' @examples
#' eval_js("123 + 456")
#' eval_js("Math.sin(1)")
#' eval_js("true")
#' eval_js("undefined")
#' 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) {
Expand Down
20 changes: 15 additions & 5 deletions packages/webr/man/eval_js.Rd

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

2 changes: 1 addition & 1 deletion packages/webr/src/webr.c
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ SEXP ffi_eval_js(SEXP code) {
const char *eval_template = "globalThis.Module.webr.evalJs(%p)";
char eval_script[BUFSIZE];
snprintf(eval_script, BUFSIZE, eval_template, R_CHAR(STRING_ELT(code, 0)));
return Rf_ScalarInteger(emscripten_run_script_int(eval_script));
return (SEXP) emscripten_run_script_int(eval_script);
#else
Rf_error("Function must be running under Emscripten.");
#endif
Expand Down
4 changes: 2 additions & 2 deletions src/esbuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,13 @@ 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', 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', 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
1 change: 1 addition & 0 deletions src/tests/webR/console.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ test('HTML canvas events call console callbacks', async () => {
}
}
globalThis.OffscreenCanvas = OffscreenCanvas;
undefined;
")
`);

Expand Down
1 change: 1 addition & 0 deletions src/tests/webR/webr-main.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -236,6 +236,7 @@ describe('Evaluate R code', () => {
}
}
globalThis.OffscreenCanvas = OffscreenCanvas;
undefined;
")
`);

Expand Down
37 changes: 26 additions & 11 deletions src/tests/webR/webr-worker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,21 +138,36 @@ describe('Execute JavaScript code from R', () => {

test('Return types are as expected', async () => {
/*
* Return type behaviour should match `emscripten_run_script_int` from
* Emscripten's C API. Note that the `eval_js` function may change in the
* JavaScript objects are converted to R objects using the `RObject` generic
* constructor. Other R object types can be returned by explicitly invoking
* another constructor. Note that the `eval_js` function may change in the
* future so as to return different types.
*/
// Integers are returned as is
const res1 = (await webR.evalR('webr::eval_js("1 + 2")')) as RInteger;
expect(await res1.toNumber()).toEqual(3);
const res1 = (await webR.evalR('webr::eval_js("123 + 456") == 579')) as RLogical;
expect(await res1.toBoolean()).toBeTruthy();

// Doubles are truncated to integer
const res2 = (await webR.evalR('webr::eval_js("Math.E")')) as RInteger;
expect(await res2.toNumber()).toEqual(2);
const res2 = (await webR.evalR(`
abs(webr::eval_js("Math.sin(1)") - sin(1)) < .Machine$double.eps
`)) as RLogical;
expect(await res2.toBoolean()).toBeTruthy();

const res3 = (await webR.evalR('webr::eval_js("true")')) as RLogical;
expect(await res3.toBoolean()).toBeTruthy();

const res4 = (await webR.evalR('is.null(webr::eval_js("undefined"))')) as RLogical;
expect(await res4.toBoolean()).toBeTruthy();

const res5 = (await webR.evalR(`
class(webr::eval_js("(new Date()).toUTCString()")) == "character"
`)) as RLogical;
expect(await res5.toBoolean()).toBeTruthy();

const res6 = (await webR.evalR(`
list <- webr::eval_js("new RList({ foo: 123, bar: 456, baz: ['a', 'b', 'c']})")
all(list$foo == 123, list$bar == 456, list$baz[[2]] == "b")
`)) as RLogical;
expect(await res6.toBoolean()).toBeTruthy();

// Other objects are converted to integer 0
const res3 = (await webR.evalR('webr::eval_js("\'abc\'")')) as RInteger;
expect(await res3.toNumber()).toEqual(0);
});
});

Expand Down
15 changes: 13 additions & 2 deletions src/webR/robj-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { Complex, isComplex, NamedEntries, NamedObject, WebRDataRaw, WebRDataSca
import { WebRData, WebRDataAtomic, RPtr, RType, RTypeMap, RTypeNumber, RCtor } from './robj';
import { isWebRDataJs, WebRDataJs, WebRDataJsAtomic, WebRDataJsNode } from './robj';
import { WebRDataJsNull, WebRDataJsString, WebRDataJsSymbol } from './robj';
import { isSimpleObject } from './utils';
import { envPoke, parseEvalBare, protect, protectInc, unprotect } from './utils-r';
import { protectWithIndex, reprotect, unprotectIndex, safeEval } from './utils-r';
import { EvalROptions, ShelterID, isShelterID } from './webr-chan';
Expand Down Expand Up @@ -95,6 +96,11 @@ function newObjectFromData(obj: WebRData): RObject {
return new (getRWorkerClass(obj.type))(obj);
}

// Map JS's 'undefined' type to R's NULL object
if (typeof obj == 'undefined') {
return new RNull();
}

// Conversion of explicit R NULL value
if (obj && typeof obj === 'object' && 'type' in obj && obj.type === 'null') {
return new RNull();
Expand Down Expand Up @@ -129,7 +135,7 @@ function newObjectFromData(obj: WebRData): RObject {
return RDataFrame.fromObject(obj);
}

throw new Error('Robj construction for this JS object is not yet supported');
throw new Error('R object construction for this JS object is not yet supported.');
}

function newObjectFromArray(arr: WebRData[]): RObject {
Expand Down Expand Up @@ -627,7 +633,12 @@ export class RList extends RObject {
protectInc(ptr, prot);

data.values.forEach((v, i) => {
Module._SET_VECTOR_ELT(ptr, i, new RObject(v).ptr);
// When we specifically use the `RList` constructor, deeply convert R objects to R lists
if (isSimpleObject(v)) {
Module._SET_VECTOR_ELT(ptr, i, new RList(v).ptr);
} else {
Module._SET_VECTOR_ELT(ptr, i, new RObject(v).ptr);
}
});

const _names = names ? names : data.names;
Expand Down
17 changes: 17 additions & 0 deletions src/webR/utils.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { IN_NODE } from './compat';
import { WebRError } from './error';
import { isComplex, isWebRDataJs } from './robj';
import { RObjectBase } from './robj-worker';

export type ResolveFn = (_value?: unknown) => void;
Expand Down Expand Up @@ -108,6 +109,22 @@ export function throwUnreachable(context?: string) {
throw new WebRError(msg);
}

export function isSimpleObject(value: any): value is {[key: string | number | symbol]: any} {
return (
typeof value === 'object' &&
value !== null &&
!Array.isArray(value) &&
!(ArrayBuffer.isView(value)) &&
!isComplex(value) &&
!isWebRDataJs(value) &&
!(value instanceof Date) &&
!(value instanceof RegExp) &&
!(value instanceof Error) &&
!(value instanceof RObjectBase) &&
Object.getPrototypeOf(value) === Object.prototype
);
}

// From https://stackoverflow.com/a/9458996
export function bufferToBase64(buffer: ArrayBuffer) {
let binary = '';
Expand Down
59 changes: 54 additions & 5 deletions src/webR/webr-worker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,6 @@ import { EmPtr, Module } from './emscripten';
import { IN_NODE } from './compat';
import { replaceInObject, throwUnreachable } from './utils';
import { WebRPayloadRaw, WebRPayloadPtr, WebRPayloadWorker, isWebRPayloadPtr } from './payload';
import { RObject, isRObject, REnvironment, RList, RCall, getRWorkerClass } from './robj-worker';
import { RCharacter, RString, keep, destroy, purge, shelters } from './robj-worker';
import { RLogical, RInteger, RDouble, initPersistentObjects, objs } from './robj-worker';
import { RPtr, RType, RCtor, WebRData, WebRDataRaw } from './robj';
import { protect, protectInc, unprotect, parseEvalBare, UnwindProtectException, safeEval } from './utils-r';
import { generateUUID } from './chan/task-common';
Expand All @@ -34,9 +31,60 @@ import {
FSSyncfsMessage,
} from './webr-chan';

import {
RCall,
RCharacter,
RComplex,
RDataFrame,
RDouble,
REnvironment,
RInteger,
RList,
RLogical,
RObject,
RPairlist,
RRaw,
RString,
RSymbol,
destroy,
getRWorkerClass,
initPersistentObjects,
isRObject,
keep,
objs,
purge,
shelters,
} from './robj-worker';

let initialised = false;
let chan: ChannelWorker | undefined;

// Make webR Worker R objects available in WorkerGlobalScope
Object.assign(globalThis, {
RCall,
RCharacter,
RComplex,
RDataFrame,
RDouble,
REnvironment,
RInteger,
RList,
RLogical,
RObject,
RPairlist,
RRaw,
RString,
RSymbol,
destroy,
getRWorkerClass,
initPersistentObjects,
isRObject,
keep,
objs,
purge,
shelters,
});

const onWorkerMessage = function (msg: Message) {
if (!msg || !msg.type) {
return;
Expand Down Expand Up @@ -842,9 +890,10 @@ function init(config: Required<WebROptions>) {
chan?.write({ type: 'view', data: { data, title } });
},

evalJs: (code: RPtr): unknown => {
evalJs: (code: RPtr): RPtr => {
try {
return (0, eval)(Module.UTF8ToString(code));
const js = (0, eval)(Module.UTF8ToString(code)) as WebRData;
return (new RObject(js)).ptr;
} catch (e) {
/* Capture continuation token and resume R's non-local transfer here.
* By resuming here we avoid potentially unwinding a target intermediate
Expand Down

0 comments on commit be0e296

Please sign in to comment.