Skip to content
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

Enable webr::eval_js() to return other types of R object #483

Merged
merged 10 commits into from
Sep 19, 2024
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