From b032bf908289f40b1f6a1ded73adaf59c4e0393e Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jul 2023 09:22:32 +0100 Subject: [PATCH 01/21] Setup COOP/COEP/CORP HTTP headers for isolation Add COOP & COEP headers to esbuild dev server for cross-origin isolation. This enables use of SharedArrayBuffer for Wasm. Add CORP header to esbuild dev server to permit embedding shinylive into an outer window. Use the shinylive service worker to add CORP/COOP/COEP headers to responses. Allows shinylive to work with webR when running in environments where it is not possible to change the HTTP headers, e.g. under GitHub pages. --- scripts/build.ts | 6 ++++++ src/messageporthttp.ts | 5 ++++- src/shinylive-sw.ts | 20 +++++++++++++++++++- 3 files changed, 29 insertions(+), 2 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 8d1d49cc..3503ffc9 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -230,6 +230,12 @@ if (serve) { http.request( { hostname: "0.0.0.0", port: 3001, path: url, method, headers }, (proxyRes) => { + proxyRes.headers = { + ...proxyRes.headers, + "cross-origin-opener-policy": "same-origin", + "cross-origin-embedder-policy": "require-corp", + "cross-origin-resource-policy": "cross-origin", + }; if (url === "/shinylive/shinylive.js") { // JS code for does auto-reloading. We'll inject it into // shinylive.js as it's sent. diff --git a/src/messageporthttp.ts b/src/messageporthttp.ts index 3e65ada5..df777d34 100644 --- a/src/messageporthttp.ts +++ b/src/messageporthttp.ts @@ -208,7 +208,10 @@ function asgiHeadersToRecord(headers: any): Record { headers = headers.map(([key, val]: [Uint8Array, Uint8Array]) => { return [uint8ArrayToString(key), uint8ArrayToString(val)]; }); - return Object.fromEntries(headers); + return Object.assign(Object.fromEntries(headers), { + "cross-origin-embedder-policy": "require-corp", + "cross-origin-resource-policy": "cross-origin", + }); } function asgiBodyToArray(body: any): Uint8Array { diff --git a/src/shinylive-sw.ts b/src/shinylive-sw.ts index 0ce1e892..068bffb3 100644 --- a/src/shinylive-sw.ts +++ b/src/shinylive-sw.ts @@ -16,6 +16,20 @@ declare const self: ServiceWorkerGlobalScope; const cacheName = "::prismExperimentsServiceworker"; const version = "v6"; +// Modify a response so that the required CORP/COOP/COEP headers are in place +// for cross-origin isolation. Required when using webR. +function addCoiHeaders(resp: Response): Response { + const headers = new Headers(resp.headers); + headers.set("Cross-Origin-Embedder-Policy", "require-corp"); + headers.set("Cross-Origin-Resource-Policy", "cross-origin"); + headers.set("Cross-Origin-Opener-Policy", "same-origin"); + return new Response(resp.body, { + status: resp.status, + statusText: resp.statusText, + headers, + }); +} + self.addEventListener("install", (event) => { event.waitUntil( Promise.all([self.skipWaiting(), caches.open(version + cacheName)]) @@ -140,7 +154,7 @@ self.addEventListener("fetch", function (event): void { } // If we got here, it wasn't in the cache. Fetch it. try { - const networkResponse = await fetch(request); + const networkResponse = addCoiHeaders(await fetch(request)); // If it's a local URL in shinylive/, cache it. const baseUrl = @@ -163,6 +177,10 @@ self.addEventListener("fetch", function (event): void { ); return; } + + event.respondWith((async (): Promise => { + return addCoiHeaders(await fetch(request)); + })()); }); // ============================================================================= From 9d638b3c23e34d677160b0df2cab39f8e0e661cc Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jul 2023 09:53:56 +0100 Subject: [PATCH 02/21] Add webR to node dependencies --- package.json | 5 +- yarn.lock | 229 ++++++++++++++++++++++++++++++++++++++++++++++++++- 2 files changed, 232 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index cfc52bd7..2983969d 100644 --- a/package.json +++ b/package.json @@ -133,5 +133,8 @@ "react-hooks/exhaustive-deps": "warn" } }, - "packageManager": "yarn@3.2.3" + "packageManager": "yarn@3.2.3", + "dependencies": { + "webr": "0.2.0-rc.0" + } } diff --git a/yarn.lock b/yarn.lock index 40ec2461..5b59825d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -425,6 +425,23 @@ __metadata: languageName: node linkType: hard +"@codemirror/autocomplete@npm:^6.8.1": + version: 6.9.0 + resolution: "@codemirror/autocomplete@npm:6.9.0" + dependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.6.0 + "@lezer/common": ^1.0.0 + peerDependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.0.0 + "@codemirror/view": ^6.0.0 + "@lezer/common": ^1.0.0 + checksum: a5f661944c75f40b02c90a193c9a459c0fd7e335c0ac5973420c19157dfb46010f573c2b70731591fe477e7a2ad10121ff3ae394a72d450946d7b886c28b0368 + languageName: node + linkType: hard + "@codemirror/commands@npm:6.2.1, @codemirror/commands@npm:^6.0.0": version: 6.2.1 resolution: "@codemirror/commands@npm:6.2.1" @@ -437,6 +454,18 @@ __metadata: languageName: node linkType: hard +"@codemirror/commands@npm:^6.2.4": + version: 6.2.4 + resolution: "@codemirror/commands@npm:6.2.4" + dependencies: + "@codemirror/language": ^6.0.0 + "@codemirror/state": ^6.2.0 + "@codemirror/view": ^6.0.0 + "@lezer/common": ^1.0.0 + checksum: 468895fa19ff0554181b698c81f850820de5c0289cab92c44392fb127286f09ca72b921d6ea4353b70b616a4fd0c3667d86b6f917202a3ad2e196eb7b581f7b6 + languageName: node + linkType: hard + "@codemirror/lang-css@npm:^6.0.0, @codemirror/lang-css@npm:^6.0.2": version: 6.0.2 resolution: "@codemirror/lang-css@npm:6.0.2" @@ -544,6 +573,13 @@ __metadata: languageName: node linkType: hard +"@codemirror/state@npm:^6.2.1": + version: 6.2.1 + resolution: "@codemirror/state@npm:6.2.1" + checksum: d12a321d0471b264b9d3259042bff913a8b939e8d28d408ff452004538a71ca9d5329df3f8a1d8a9183f5b42a7ef5b200737bcab1065714f5ae8e0a5ba9d59d3 + languageName: node + linkType: hard + "@codemirror/view@npm:^6.0.0, @codemirror/view@npm:^6.2.2, @codemirror/view@npm:^6.6.0, @codemirror/view@npm:^6.9.1": version: 6.9.1 resolution: "@codemirror/view@npm:6.9.1" @@ -555,6 +591,17 @@ __metadata: languageName: node linkType: hard +"@codemirror/view@npm:^6.15.0": + version: 6.15.3 + resolution: "@codemirror/view@npm:6.15.3" + dependencies: + "@codemirror/state": ^6.1.4 + style-mod: ^4.0.0 + w3c-keyname: ^2.2.4 + checksum: 048949b1b493a962904a7f77661a939f7c1893a7381022756a135f5dd8daf667f498be1b81da9c37c0e8de85b078ad987c2f75318385c520ed83c95da6313e95 + languageName: node + linkType: hard + "@esbuild-kit/cjs-loader@npm:^2.4.2": version: 2.4.2 resolution: "@esbuild-kit/cjs-loader@npm:2.4.2" @@ -1359,6 +1406,13 @@ __metadata: languageName: node linkType: hard +"@msgpack/msgpack@npm:^2.8.0": + version: 2.8.0 + resolution: "@msgpack/msgpack@npm:2.8.0" + checksum: bead9393f57239007a2fe455df5277cbc03b125f14f310162a652b81471dcf3ab6780eaa24b36e20aa742998910a6840147d08b7267063b8e2de5d40c624360e + languageName: node + linkType: hard + "@nodelib/fs.scandir@npm:2.1.5": version: 2.1.5 resolution: "@nodelib/fs.scandir@npm:2.1.5" @@ -2503,6 +2557,16 @@ __metadata: languageName: node linkType: hard +"codemirror-lang-r@npm:^0.1.0-2": + version: 0.1.0-2 + resolution: "codemirror-lang-r@npm:0.1.0-2" + dependencies: + "@codemirror/language": ^6.0.0 + lezer-r: ^0.1.0-5 + checksum: 81f83dd525196b666663b5430e1015f347d7405cc34c79f94ca86838568d9ac22a902e3a42ac0f03f575cc38aca2962f8a1846a3c39d4543b32fbeccb316ce49 + languageName: node + linkType: hard + "codemirror@npm:^6.0.1": version: 6.0.1 resolution: "codemirror@npm:6.0.1" @@ -2751,6 +2815,15 @@ __metadata: languageName: node linkType: hard +"detect-libc@npm:^1.0.3": + version: 1.0.3 + resolution: "detect-libc@npm:1.0.3" + bin: + detect-libc: ./bin/detect-libc.js + checksum: daaaed925ffa7889bd91d56e9624e6c8033911bb60f3a50a74a87500680652969dbaab9526d1e200a4c94acf80fc862a22131841145a0a8482d60a99c24f4a3e + languageName: node + linkType: hard + "detect-newline@npm:^3.0.0": version: 3.1.0 resolution: "detect-newline@npm:3.1.0" @@ -5137,6 +5210,106 @@ __metadata: languageName: node linkType: hard +"lezer-r@npm:^0.1.0-5": + version: 0.1.0-5 + resolution: "lezer-r@npm:0.1.0-5" + dependencies: + "@lezer/highlight": ^1.0.0 + "@lezer/lr": ^1.0.0 + checksum: b597aa0add8fb4b7afd88c11766b2100ad52992beb0cebac844949974c18c2b97d5a7c0c6ecd6d464a71d692a94e17d8a7a744252166c2366fd8718b5ef510fc + languageName: node + linkType: hard + +"lightningcss-darwin-arm64@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-darwin-arm64@npm:1.21.5" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"lightningcss-darwin-x64@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-darwin-x64@npm:1.21.5" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.21.5" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"lightningcss-linux-arm64-gnu@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-linux-arm64-gnu@npm:1.21.5" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-arm64-musl@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-linux-arm64-musl@npm:1.21.5" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-linux-x64-gnu@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-linux-x64-gnu@npm:1.21.5" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"lightningcss-linux-x64-musl@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-linux-x64-musl@npm:1.21.5" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"lightningcss-win32-x64-msvc@npm:1.21.5": + version: 1.21.5 + resolution: "lightningcss-win32-x64-msvc@npm:1.21.5" + conditions: os=win32 & cpu=x64 + languageName: node + linkType: hard + +"lightningcss@npm:^1.21.5": + version: 1.21.5 + resolution: "lightningcss@npm:1.21.5" + dependencies: + detect-libc: ^1.0.3 + lightningcss-darwin-arm64: 1.21.5 + lightningcss-darwin-x64: 1.21.5 + lightningcss-linux-arm-gnueabihf: 1.21.5 + lightningcss-linux-arm64-gnu: 1.21.5 + lightningcss-linux-arm64-musl: 1.21.5 + lightningcss-linux-x64-gnu: 1.21.5 + lightningcss-linux-x64-musl: 1.21.5 + lightningcss-win32-x64-msvc: 1.21.5 + dependenciesMeta: + lightningcss-darwin-arm64: + optional: true + lightningcss-darwin-x64: + optional: true + lightningcss-linux-arm-gnueabihf: + optional: true + lightningcss-linux-arm64-gnu: + optional: true + lightningcss-linux-arm64-musl: + optional: true + lightningcss-linux-x64-gnu: + optional: true + lightningcss-linux-x64-musl: + optional: true + lightningcss-win32-x64-msvc: + optional: true + checksum: fcfb80302740c275fea8dc6ebc1a31da681321d92f0ddb33deb71037bb3dee08b8143ee03ddad8cef39c7ba4000447b362c9295d264deec124c9a206a848fef4 + languageName: node + linkType: hard + "lines-and-columns@npm:^1.1.6": version: 1.2.4 resolution: "lines-and-columns@npm:1.2.4" @@ -6001,6 +6174,18 @@ __metadata: languageName: node linkType: hard +"react-accessible-treeview@npm:^2.6.1": + version: 2.6.3 + resolution: "react-accessible-treeview@npm:2.6.3" + peerDependencies: + classnames: ^2.2.6 + prop-types: ^15.7.2 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + checksum: 7dbca4fc7cfa94ae451212e283bd474a319a1dcd01ef64d65362c738edf4b0773c74da85cd2350bb407b599220d99ee7e84c64ea9e169840ffd088490202ce1b + languageName: node + linkType: hard + "react-dom@npm:^18.2.0": version: 18.2.0 resolution: "react-dom@npm:18.2.0" @@ -6013,6 +6198,15 @@ __metadata: languageName: node linkType: hard +"react-icons@npm:^4.10.1": + version: 4.10.1 + resolution: "react-icons@npm:4.10.1" + peerDependencies: + react: "*" + checksum: b6c8d4fe482b112e1041515d93ef7670a0af128855c926052a076b64d0b991cdd4391394b26467100f8da3859db1ebd8f9c6b7614fa5969fde83de45c71031a3 + languageName: node + linkType: hard + "react-is@npm:^16.13.1": version: 16.13.1 resolution: "react-is@npm:16.13.1" @@ -6348,6 +6542,7 @@ __metadata: tsx: ^3.12.7 typescript: ^5.1.3 vscode-languageserver-protocol: ^3.17.3 + webr: 0.2.0-rc.0 xterm: ^5.2.1 xterm-addon-fit: ^0.7.0 xterm-readline: ^1.1.1 @@ -7029,6 +7224,31 @@ __metadata: languageName: node linkType: hard +"webr@npm:0.2.0-rc.0": + version: 0.2.0-rc.0 + resolution: "webr@npm:0.2.0-rc.0" + dependencies: + "@codemirror/autocomplete": ^6.8.1 + "@codemirror/commands": ^6.2.4 + "@codemirror/state": ^6.2.1 + "@codemirror/view": ^6.15.0 + "@msgpack/msgpack": ^2.8.0 + codemirror: ^6.0.1 + codemirror-lang-r: ^0.1.0-2 + lightningcss: ^1.21.5 + react: ^18.2.0 + react-accessible-treeview: ^2.6.1 + react-dom: ^18.2.0 + react-icons: ^4.10.1 + tsx: ^3.12.7 + xmlhttprequest-ssl: ^2.1.0 + xterm: ^5.1.0 + xterm-addon-fit: ^0.7.0 + xterm-readline: ^1.1.1 + checksum: 51cdc7d75256845415eb41672329531e74efa37ee6d016a1bc3b6a4dba1897c7a0eafa0b89655450adc88986d8f3c64be18b547a1d2532f184348591c7607df2 + languageName: node + linkType: hard + "whatwg-encoding@npm:^1.0.5": version: 1.0.5 resolution: "whatwg-encoding@npm:1.0.5" @@ -7192,6 +7412,13 @@ __metadata: languageName: node linkType: hard +"xmlhttprequest-ssl@npm:^2.1.0": + version: 2.1.1 + resolution: "xmlhttprequest-ssl@npm:2.1.1" + checksum: f427f17c3692e1832c327a0a19d4dfec5cfcc669b79b26a51ac35fd7a17ab474c3bb7339673b12130b2311ce1b251fe15dfc65aabc1c839862f01f138c71985b + languageName: node + linkType: hard + "xterm-addon-fit@npm:^0.7.0": version: 0.7.0 resolution: "xterm-addon-fit@npm:0.7.0" @@ -7212,7 +7439,7 @@ __metadata: languageName: node linkType: hard -"xterm@npm:^5.2.1": +"xterm@npm:^5.1.0, xterm@npm:^5.2.1": version: 5.2.1 resolution: "xterm@npm:5.2.1" checksum: 3a9de30e772c7ae30895ec97fcfb3b0906429c5ea0cddf8948e8e30301385f82e467c6e6aca28ae50a48300ce795381d83fe35b4e17886ab4a1357054a15f68f From e428c858b717f6534c79ec5c4f22fb270ad7eaf2 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jul 2023 09:54:12 +0100 Subject: [PATCH 03/21] Initial setup of React hook for webR --- Makefile | 8 ++- src/hooks/useWebR.tsx | 115 ++++++++++++++++++++++++++++++++++++++ src/webr-proxy.ts | 127 ++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 249 insertions(+), 1 deletion(-) create mode 100644 src/hooks/useWebR.tsx create mode 100644 src/webr-proxy.ts diff --git a/Makefile b/Makefile index 184486d5..08bec34d 100644 --- a/Makefile +++ b/Makefile @@ -9,7 +9,7 @@ packages \ quarto quartoserve \ clean-packages clean distclean \ - test test-watch + test test-watch webr .DEFAULT_GOAL := help @@ -93,6 +93,7 @@ submodules-pull: all: node_modules \ $(BUILD_DIR)/shinylive/style-resets.css \ $(BUILD_DIR)/shinylive/pyodide \ + $(BUILD_DIR)/shinylive/webr \ src/pyodide/pyodide.js \ src/pyodide/pyodide.d.ts \ pyodide_packages_local \ @@ -126,6 +127,11 @@ $(BUILD_DIR)/shinylive/pyodide: curl -L https://github.com/pyodide/pyodide/releases/download/$(PYODIDE_VERSION)/$(PYODIDE_DIST_FILENAME) \ | tar --exclude "*test*.tar" --exclude "node_modules" -xvj +$(BUILD_DIR)/shinylive/webr: webr +webr: + mkdir -p $(BUILD_DIR)/shinylive/webr + cp -r node_modules/webr/dist/. $(BUILD_DIR)/shinylive/webr + # Copy pyodide.js and .d.ts to src/pyodide/. This is a little weird in that in # `make all`, it comes after downloading pyodide. In the future we may be able # to use a pyodide node module, but the one currently on npm is a bit out of diff --git a/src/hooks/useWebR.tsx b/src/hooks/useWebR.tsx new file mode 100644 index 00000000..008e8655 --- /dev/null +++ b/src/hooks/useWebR.tsx @@ -0,0 +1,115 @@ +import React, { useEffect } from "react"; +import * as utils from "../utils"; +import { WebRProxy, loadWebRProxy } from '../webr-proxy'; + +export type WebRProxyHandle = + | { + ready: false; + shinyReady: false; + initError: false; + } + | { + ready: true; + webRProxy: WebRProxy; + shinyReady: boolean; + initError: boolean; + // Run code through webR REPL. Returns a promise to the next prompt. + runCode: (command: string) => Promise; + tabComplete: (command: string) => Promise; + interrupt: () => void; + }; + +export async function initWebR({ + stdout, + stderr, +}: { + stdout?: (msg: string) => any; + stderr?: (msg: string) => any; +}): Promise { + // Defaults for stdout and stderr if not provided: log to console + if (!stdout) stdout = (x: string) => console.log("webR echo:" + x); + if (!stderr) stderr = (x: string) => console.error("webR error:" + x); + + const webRProxy = await loadWebRProxy( + { baseUrl: utils.currentScriptDir() + "/webr/" }, + stdout, + stderr + ); + + let initError = false; + try { + await webRProxy.runRAsync('webr::install("codetools")') + await webRProxy.runRAsync(load_r_pre); + } catch (e) { + initError = true; + console.error(e); + } + + async function runCode(command: string) { + return await webRProxy.runCode(command); + } + + async function tabComplete(code: string): Promise { + return ['']; + } + + function interrupt() { + webRProxy.webR.interrupt(); + } + + return { + ready: true, + webRProxy, + shinyReady: false, + initError: initError, + runCode, + tabComplete, + interrupt, + }; +} + +export async function initRShiny({ + webRProxyHandle, +}: { + webRProxyHandle: WebRProxyHandle; +}): Promise { + if (!webRProxyHandle.ready) { + throw new Error("webRProxyHandle is not ready"); + } + + await webRProxyHandle.webRProxy.runRAsync('webr::install("shiny")') + await webRProxyHandle.webRProxy.runRAsync('library(shiny)') + // Increase webR expressions limit for deep call stack required for Shiny + await webRProxyHandle.webRProxy.runRAsync('options(expressions=1000)') + + return { + ...webRProxyHandle, + shinyReady: true, + }; +} + +export function useWebR({ + webRProxyHandlePromise, +}: { + webRProxyHandlePromise: Promise; +}) { + const [webRProxyHandle, setwebRProxyHandle] = React.useState({ + ready: false, + shinyReady: false, + initError: false, + }); + + useEffect(() => { + (async () => { + const webRProxyHandle = await webRProxyHandlePromise; + setwebRProxyHandle(webRProxyHandle); + })(); + }, [webRProxyHandlePromise]); + + return webRProxyHandle; +} + +const load_r_pre = +` +invisible(0) +` diff --git a/src/webr-proxy.ts b/src/webr-proxy.ts new file mode 100644 index 00000000..77971d61 --- /dev/null +++ b/src/webr-proxy.ts @@ -0,0 +1,127 @@ +import { ASGIHTTPRequestScope } from "./messageporthttp.js"; +import { Shelter, WebR, WebROptions } from "webr"; +import type { EvalROptions } from "webr/webr-chan"; + +export interface WebRProxy { + webR: WebR; + + runRAsync( + code: string, + options?: EvalROptions, + ): Promise; + + runCode(code: string): Promise; + + openChannel( + path: string, + appName: string, + clientPort: MessagePort + ): Promise; + + makeRequest( + scope: ASGIHTTPRequestScope, + appName: string, + clientPort: MessagePort + ): Promise; +} + +class WebRWorkerProxy implements WebRProxy { + webR: WebR; + shelter?: Shelter; + prompt?: { + resolve: (prompt: string) => void; + reject: () => void; + }; + + constructor( + config: WebROptions, + private stdoutCallback: (text: string) => void, + private stderrCallback: (text: string) => void + ) { + this.webR = new WebR(config); + } + + async runCode(code: string) { + const waitForPrompt = new Promise((resolve, reject) => { + this.prompt = { + resolve, + reject, + } + }); + this.webR.writeConsole(code); + return await waitForPrompt; + } + + async runRAsync( + code: string, + options: EvalROptions = {}, + ): Promise { + if (!options.captureStreams) { + options.captureStreams = false + } + if (!options.captureConditions) { + options.captureConditions = false + } + await this.webR.init(); + if (!this.shelter) this.shelter = await new this.webR.Shelter(); + try { + return await this.shelter.evalR(code, options); + } catch (e) { + this.stderrCallback((e as Error).message); + } finally { + this.shelter.purge(); + } + } + + run() { + this.#run(); + } + + async #run() { + for (;;) { + const output = await this.webR.read(); + switch (output.type) { + case 'stdout': + this.stdoutCallback(output.data); + break; + case 'stderr': + this.stderrCallback(output.data); + break; + case 'prompt': + if (this.prompt) { + this.prompt.resolve(output.data); + } + break; + default: + break; + } + } + } + + async openChannel( + path: string, + appName: string, + clientPort: MessagePort + ): Promise { + + } + + async makeRequest( + scope: ASGIHTTPRequestScope, + appName: string, + clientPort: MessagePort + ): Promise { + + } +} + +export async function loadWebRProxy( + config: WebROptions, + stdoutCallback: (text: string) => void = console.log, + stderrCallback: (text: string) => void = console.error +): Promise { + const webRProxy = new WebRWorkerProxy(config, stdoutCallback, stderrCallback); + await webRProxy.webR.init; + webRProxy.run(); + return webRProxy; +} From b44c422df4d1604918df642ae63c625c38d5935e Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 24 Jul 2023 12:02:47 +0100 Subject: [PATCH 04/21] Add infrastructure for webR httpuv communication --- src/hooks/useWebR.tsx | 50 ++++++++++++ src/messageporthttp.ts | 118 ++++++++++++++++++++++++++++ src/messageportwebsocket-channel.ts | 88 +++++++++++++++++++++ src/webr-proxy.ts | 23 +++++- 4 files changed, 276 insertions(+), 3 deletions(-) diff --git a/src/hooks/useWebR.tsx b/src/hooks/useWebR.tsx index 008e8655..b8ec9a76 100644 --- a/src/hooks/useWebR.tsx +++ b/src/hooks/useWebR.tsx @@ -81,6 +81,7 @@ export async function initRShiny({ await webRProxyHandle.webRProxy.runRAsync('library(shiny)') // Increase webR expressions limit for deep call stack required for Shiny await webRProxyHandle.webRProxy.runRAsync('options(expressions=1000)') + ensureOpenChannelListener(webRProxyHandle.webRProxy); return { ...webRProxyHandle, @@ -109,7 +110,56 @@ export function useWebR({ return webRProxyHandle; } +let channelListenerRegistered = false; +function ensureOpenChannelListener(webRProxy: WebRProxy): void { + if (channelListenerRegistered) return; + + window.addEventListener("message", (event) => { + const msg = event.data; + if (msg.type === "openChannel") { + webRProxy.openChannel(msg.path, msg.appName, event.ports[0]); + } + }); + + channelListenerRegistered = true; +} + const load_r_pre = ` +.RawReader <- setRefClass("RawReader", fields = c("con", "length"), methods = list( + init = function(bytes) { + con <<- rawConnection(bytes, "rb") + length <<- length(bytes) + }, + read = function(l = -1L) { + if (l < 0) l <- length + readBin(con, "raw", size = 1, n = l) + }, + read_lines = function(l = -1L) { + readLines(con, n = l) + }, + rewind = function() { + seek(con, 0) + }, + destroy = function() { + close(con) + } +)) + +.stop_app <- function() { + webr::eval_js(" + chan.write({ + type: '_webR_httpuv_WSResponse', + data: { handle: '1', binary: false, type: 'websocket.close', message: 'stopped' } + }); + ") + shiny::stopApp() +} + +.start_app <- function (appDir) { + shiny::runApp(appDir, port=0, host=NULL) + invisible(0) +} + invisible(0) ` diff --git a/src/messageporthttp.ts b/src/messageporthttp.ts index df777d34..5169fab3 100644 --- a/src/messageporthttp.ts +++ b/src/messageporthttp.ts @@ -3,6 +3,9 @@ import { loadPyodide } from "./pyodide/pyodide"; import type { PyProxyCallable } from "./pyodide/pyodide"; import { uint8ArrayToString } from "./utils"; +// ============================================================================= +// Pyodide +// ============================================================================= type Pyodide = Awaited>; export async function fetchASGI( @@ -219,3 +222,118 @@ function asgiBodyToArray(body: any): Uint8Array { // return Uint8Array.from(body.toJs()); return body; } + +// ============================================================================= +// webR +// ============================================================================= +import { WebRProxy } from "./webr-proxy"; +import { makeRandomKey } from "./utils"; + +export async function makeHttpuvRequest( + scope: ASGIHTTPRequestScope, + appName: string, + clientPort: MessagePort, + webRProxy: WebRProxy +){ + const fromClientQueue = new AwaitableQueue>(); + + clientPort.addEventListener("message", (event) => { + if (event.data.type === "http.request") { + fromClientQueue.enqueue({ + type: "http.request", + body: event.data.body, + more_body: event.data.more_body, + }); + } + }); + clientPort.start(); + + async function fromClient(): Promise> { + return fromClientQueue.dequeue(); + } + + async function toClient(event: Record): Promise { + const status = event.status.values[0]; + const utf8Encode = new TextEncoder(); + const body = event.body.type === 'raw' + ? new Uint8Array(event.body.values) + : utf8Encode.encode(event.body.values[0]); + + const headers = Object.assign( + { + "cross-origin-embedder-policy": "require-corp", + "cross-origin-resource-policy": "cross-origin", + }, + Object.fromEntries( + [...Array(event.headers.names.length).keys()].map((i) => { + return [event.headers.names[i], event.headers.values[i].values[0]]; + }) + ) + ); + + clientPort.postMessage({ + type: "http.response.start", + status: status, + headers: headers, + }); + + clientPort.postMessage({ + type: "http.response.body", + body: body, + more_body: false, + }); + } + await handleHttpuvRequests(scope, webRProxy, fromClient, toClient); +} + +async function handleHttpuvRequests( + scope: ASGIHTTPRequestScope, + webRProxy: WebRProxy, + fromClient: () => Promise>, + toClient: (event: Record) => Promise +){ + const uuid = makeRandomKey(20); + webRProxy.toClientCache[uuid] = toClient; + let body = new Uint8Array(0); + const shelter = await new webRProxy.webR.Shelter(); + for (;;) { + // Get request from client + const request = await fromClient(); + + // Concatenate body bytes as subsequent requests arrive + if (request.body) { + const newBody = new Uint8Array(body.length + request.body.length); + newBody.set(body); + newBody.set(request.body, body.length); + body = newBody; + } + + // Once we have the entire body, convert it into a rook style request and + // send it to httpuv + if (!request.more_body) { + try { + const bytes = await new shelter.RRaw(Array.from(body)); + const env = await new shelter.REnvironment({ bytes }); + await webRProxy.runRAsync(` + onRequest <- options('webr_httpuv_onRequest')[[1]] + if (!is.null(onRequest)) { + reader <- .RawReader$new() + reader$init(bytes) + onRequest( + list( + PATH_INFO = "${scope.path}", + REQUEST_METHOD = "${scope.method}", + QUERY_STRING = "${scope.query_string}", + UUID = "${uuid}", + rook.input = reader + ) + ) + reader$destroy() + } + `, { env }) + } finally { + shelter.purge(); + } + } + } +} diff --git a/src/messageportwebsocket-channel.ts b/src/messageportwebsocket-channel.ts index bce56421..a44e521a 100644 --- a/src/messageportwebsocket-channel.ts +++ b/src/messageportwebsocket-channel.ts @@ -3,6 +3,9 @@ import { MessagePortWebSocket } from "./messageportwebsocket"; import { loadPyodide } from "./pyodide/pyodide"; import type { PyProxyCallable } from "./pyodide/pyodide"; +// ============================================================================= +// Pyodide +// ============================================================================= type Pyodide = Awaited>; /** @@ -95,3 +98,88 @@ async function connect( // connection is closed. await asgiFunc(scope, fromClient, toClient); } + +// ============================================================================= +// webR +// ============================================================================= +import { WebRProxy } from "./webr-proxy"; + +export async function openChannelHttpuv( + path: string, + appName: string, + clientPort: MessagePort, + webRProxy: WebRProxy, +): Promise { + const conn = new MessagePortWebSocket(clientPort); + let connected = false; + + async function toClient(event: Record): Promise { + if (!connected) { + conn.accept(); + connected = true; + } + if (event.type === "websocket.send") { + conn.send(event.message); + } else if (event.type === "websocket.close") { + connected = false; + conn.close(1000, event.message); + } else { + connected = false; + conn.close(1002, "ASGI protocol error"); + throw new Error(`Unhandled ASGI event: ${event.type}`); + } + } + webRProxy.toClientCache['ws'] = toClient; + + const fromClientQueue = new AwaitableQueue>(); + fromClientQueue.enqueue({ type: "websocket.connect" }); + + conn.addEventListener("message", (e) => { + const me = e as MessageEvent; + const event: Record = { type: "websocket.receive" }; + event.text = me.data; + fromClientQueue.enqueue(event); + }); + + conn.addEventListener("close", (e) => { + const ce = e as CloseEvent; + fromClientQueue.enqueue({ type: "websocket.disconnect", code: ce.code }); + }); + + conn.addEventListener("error", (e) => { + console.error(e); + }); + + for(;;){ + const msg = await fromClientQueue.dequeue(); + switch(msg.type){ + case 'websocket.connect': + webRProxy.runRAsync(` + onWSOpen <- options('webr_httpuv_onWSOpen')[[1]] + if (!is.null(onWSOpen)) { + onWSOpen(1, list(handle = 1)) + } + `) + break; + case 'websocket.receive': + webRProxy.runRAsync(` + onWSMessage <- options('webr_httpuv_onWSMessage')[[1]] + if (!is.null(onWSMessage)) { + onWSMessage(1, FALSE, '${msg.text}') + } + `) + break; + case 'websocket.disconnect': + webRProxy.runRAsync(` + onWSClose <- options('webr_httpuv_onWSClose')[[1]] + if (!is.null(onWSClose)) { + onWSClose(1) + } + `) + break; + default: + console.warn(`Unhandled websocket message of type "${msg.type}".`) + return; + } + } +} diff --git a/src/webr-proxy.ts b/src/webr-proxy.ts index 77971d61..8948f45e 100644 --- a/src/webr-proxy.ts +++ b/src/webr-proxy.ts @@ -1,9 +1,11 @@ -import { ASGIHTTPRequestScope } from "./messageporthttp.js"; +import { ASGIHTTPRequestScope, makeHttpuvRequest } from "./messageporthttp.js"; +import { openChannelHttpuv } from "./messageportwebsocket-channel.js"; import { Shelter, WebR, WebROptions } from "webr"; import type { EvalROptions } from "webr/webr-chan"; export interface WebRProxy { webR: WebR; + toClientCache: { [key: string]: (event: Record) => Promise }; runRAsync( code: string, @@ -32,6 +34,7 @@ class WebRWorkerProxy implements WebRProxy { resolve: (prompt: string) => void; reject: () => void; }; + toClientCache: WebRProxy['toClientCache'] = {}; constructor( config: WebROptions, @@ -92,6 +95,20 @@ class WebRWorkerProxy implements WebRProxy { this.prompt.resolve(output.data); } break; + case '_webR_httpuv_TcpResponse': { + const msg = output as { + data?: any; + type: string; + uuid: string; + } + this.toClientCache[msg.uuid](msg.data); + break; + } + case '_webR_httpuv_WSResponse': { + const toClient = this.toClientCache['ws']; + if (typeof toClient !== 'undefined') toClient(output.data); + break; + } default: break; } @@ -103,7 +120,7 @@ class WebRWorkerProxy implements WebRProxy { appName: string, clientPort: MessagePort ): Promise { - + openChannelHttpuv(path, appName, clientPort, this); } async makeRequest( @@ -111,7 +128,7 @@ class WebRWorkerProxy implements WebRProxy { appName: string, clientPort: MessagePort ): Promise { - + makeHttpuvRequest(scope, appName, clientPort, this); } } From 0bffff577636d941bad49947303d48facc06cd56 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 25 Jul 2023 14:47:58 +0100 Subject: [PATCH 05/21] Init pyodide/webR engine based on runApp arg A new argument `engine` is added to runApp() that selects webR or Pyodide as the underlying engine to use. The main app selects a React hook `useWebR()` or `usePyodide()` based on this argument. The proxy handle variable `proxyHandle` is typed as a union, `ProxyHandle = PyodideProxyHandle | WebRProxyHandle`, and the property `engine` is added as a way to discriminate between which Wasm engine is currently loaded. Hooks relying on Pyodide are disabled through early return when the `proxyHandle.engine` property is not equal to "pyodide". --- src/Components/App.tsx | 110 ++++++++++++++++++++++++++++++--------- src/hooks/usePyodide.tsx | 2 + src/hooks/useWebR.tsx | 2 + 3 files changed, 88 insertions(+), 26 deletions(-) diff --git a/src/Components/App.tsx b/src/Components/App.tsx index f74ddb89..077d56f5 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -9,6 +9,12 @@ import { PyodideProxyHandle, usePyodide, } from "../hooks/usePyodide"; +import { + initWebR, + initRShiny, + useWebR, + WebRProxyHandle +} from "../hooks/useWebR"; import { ProxyType } from "../pyodide-proxy"; import "./App.css"; import { ExampleSelector } from "./ExampleSelector"; @@ -63,6 +69,7 @@ const pyodideProxyType: ProxyType = ? "normal" : "webworker"; +export type AppEngine = "python" | "r"; export type AppMode = | "examples-editor-terminal-viewer" | "editor-terminal-viewer" @@ -90,7 +97,10 @@ type AppOptions = { showHeaderBar?: boolean; }; + +type ProxyHandle = PyodideProxyHandle | WebRProxyHandle; let pyodideProxyHandlePromise: Promise | null = null; +let webRProxyHandlePromise: Promise | null = null; function ensurePyodideProxyHandlePromise({ proxyType, @@ -132,14 +142,42 @@ function ensurePyodideProxyHandlePromise({ return pyodideProxyHandlePromise; } +function ensureWebRProxyHandlePromise({ + shiny, +}: { + shiny: boolean; +}): Promise { + if (!webRProxyHandlePromise) { + webRProxyHandlePromise = (async (): Promise => { + let webRProxyHandle = await initWebR({ + stdout: terminalInterface.echo, + stderr: terminalInterface.error, + }); + + if (shiny) { + webRProxyHandle = await initRShiny({ webRProxyHandle }); + } + + if (!webRProxyHandle.initError) { + terminalInterface.clear(); + } + + return webRProxyHandle; + })(); + } + return webRProxyHandlePromise as Promise; +} + export function App({ appMode = "examples-editor-terminal-viewer", startFiles = [], appOptions = {}, + appEngine = "python" }: { appMode: AppMode; startFiles: FileContent[]; appOptions?: AppOptions; + appEngine: AppEngine; }) { if (startFiles.length === 0) { if (appMode.includes("viewer")) { @@ -167,15 +205,33 @@ export function App({ // For most but not all appMode, set up pyodide for shiny. const loadShiny = !["editor-terminal"].includes(appMode); - // Temporarily disabled - // Modes in which _not_ to show Pyodide startup message. - // const showStartBanner = !["editor-terminal"].includes(appMode); - pyodideProxyHandlePromise = ensurePyodideProxyHandlePromise({ - proxyType: pyodideProxyType, - shiny: loadShiny, - showStartBanner: false, - }); - const pyodideProxyHandle = usePyodide({ pyodideProxyHandlePromise }); + let useWasmEngine: () => ProxyHandle; + switch (appEngine) { + case "python": { + // Temporarily disabled + // Modes in which _not_ to show Pyodide startup message. + // const showStartBanner = !["editor-terminal"].includes(appMode); + const promise = ensurePyodideProxyHandlePromise({ + proxyType: pyodideProxyType, + shiny: loadShiny, + showStartBanner: false, + }); + pyodideProxyHandlePromise = promise; + useWasmEngine = () => usePyodide({ pyodideProxyHandlePromise: promise }); + break; + } + case "r":{ + const promise = webRProxyHandlePromise = ensureWebRProxyHandlePromise({ + shiny: loadShiny, + }); + useWasmEngine = () => useWebR({ webRProxyHandlePromise: promise }); + break; + } + default: + throw new Error(`Unrecognised Wasm engine: "${appEngine}".`); + } + + const proxyHandle = useWasmEngine(); const [viewerMethods, setViewerMethods] = React.useState({ ready: false, @@ -209,11 +265,12 @@ export function App({ // editor won't be saved. React.useEffect(() => { (async () => { - if (!pyodideProxyHandle.ready) return; + if (!proxyHandle.ready) return; + if (proxyHandle.engine !== "pyodide") return; if (currentFiles.some((file) => file.name === "app.py")) return; // Save the code in /home/pyodide - await pyodideProxyHandle.pyodide.callPyAsync({ + await proxyHandle.pyodide.callPyAsync({ fnName: ["_save_files"], kwargs: { files: currentFiles, @@ -222,7 +279,7 @@ export function App({ }, }); })(); - }, [pyodideProxyHandle.ready, currentFiles]); + }, [proxyHandle.ready, currentFiles]); const [utilityMethods, setUtilityMethods] = React.useState({ formatCode: async (code: string) => { @@ -231,11 +288,12 @@ export function App({ }); React.useEffect(() => { - if (!pyodideProxyHandle.ready) return; + if (!proxyHandle.ready) return; + if (proxyHandle.engine !== "pyodide") return; setUtilityMethods({ formatCode: async (code: string) => { - const result = await pyodideProxyHandle.pyodide.callPyAsync({ + const result = await proxyHandle.pyodide.callPyAsync({ fnName: ["_format_py_code"], args: [code], returnResult: "value", @@ -244,7 +302,7 @@ export function App({ }, }); if (currentFiles.some((file) => file.name === "app.py")) return; - }, [pyodideProxyHandle.ready, currentFiles]); + }, [proxyHandle.ready, currentFiles]); React.useEffect(() => { if (appMode !== "viewer") return; @@ -290,12 +348,12 @@ export function App({ /> @@ -327,12 +385,12 @@ export function App({ /> @@ -358,7 +416,7 @@ export function App({ /> @@ -382,7 +440,7 @@ export function App({ /> @@ -418,7 +476,7 @@ export function App({ /> @@ -438,7 +496,7 @@ export function App({ }} > @@ -464,14 +522,14 @@ export function runApp( allowCodeUrl?: boolean; allowGistUrl?: boolean; allowExampleUrl?: boolean; - } = {} + } = {}, + appEngine: AppEngine = "python", ) { const optsDefaults = { allowCodeUrl: false, allowGistUrl: false, allowExampleUrl: false, }; - opts = { ...optsDefaults, ...opts }; let startFiles: undefined | FileContentJson[] | FileContent[] = opts.startFiles; @@ -563,7 +621,7 @@ export function runApp( const root = createRoot(domTarget); root.render( - + ); })(); diff --git a/src/hooks/usePyodide.tsx b/src/hooks/usePyodide.tsx index ef8009df..5a31535d 100644 --- a/src/hooks/usePyodide.tsx +++ b/src/hooks/usePyodide.tsx @@ -10,6 +10,7 @@ export type PyodideProxyHandle = } | { ready: true; + engine: "pyodide"; pyodide: PyodideProxy; shinyReady: boolean; initError: boolean; @@ -72,6 +73,7 @@ export async function initPyodide({ return { ready: true, + engine: "pyodide", pyodide: pyodideProxy, shinyReady: false, initError: initError, diff --git a/src/hooks/useWebR.tsx b/src/hooks/useWebR.tsx index b8ec9a76..04faec63 100644 --- a/src/hooks/useWebR.tsx +++ b/src/hooks/useWebR.tsx @@ -10,6 +10,7 @@ export type WebRProxyHandle = } | { ready: true; + engine: "webr"; webRProxy: WebRProxy; shinyReady: boolean; initError: boolean; @@ -59,6 +60,7 @@ export async function initWebR({ return { ready: true, + engine: "webr", webRProxy, shinyReady: false, initError: initError, From 8997f36e91c113c7ff0142553bd83e7caa984883 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 25 Jul 2023 15:16:10 +0100 Subject: [PATCH 06/21] Support for webR in the Terminal component * Update Terminal component to use a general `proxyHandle`. * Tweak the `runCode` Ref to return the next prompt as a string when running with the webR engine. This allows for the debuging `Browse[1]>` and input `readline()` prompts when using R. * Maintain default ">>> " prompt when running under Pyodide. --- src/Components/App.tsx | 8 +++---- src/Components/Terminal.tsx | 42 ++++++++++++++++++------------------- 2 files changed, 25 insertions(+), 25 deletions(-) diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 077d56f5..2d944934 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -98,7 +98,7 @@ type AppOptions = { }; -type ProxyHandle = PyodideProxyHandle | WebRProxyHandle; +export type ProxyHandle = PyodideProxyHandle | WebRProxyHandle; let pyodideProxyHandlePromise: Promise | null = null; let webRProxyHandlePromise: Promise | null = null; @@ -348,7 +348,7 @@ export function App({ /> @@ -385,7 +385,7 @@ export function App({ /> @@ -416,7 +416,7 @@ export function App({ /> diff --git a/src/Components/Terminal.tsx b/src/Components/Terminal.tsx index faeb01bc..a1e58054 100644 --- a/src/Components/Terminal.tsx +++ b/src/Components/Terminal.tsx @@ -1,4 +1,4 @@ -import { PyodideProxyHandle } from "../hooks/usePyodide"; +import { ProxyHandle } from "./App"; import "./Terminal.css"; import * as React from "react"; import { Terminal as XTerminal } from "xterm"; @@ -29,11 +29,11 @@ export type TerminalMethods = // Terminal component // ============================================================================= export function Terminal({ - pyodideProxyHandle, + proxyHandle, setTerminalMethods, terminalInterface, }: { - pyodideProxyHandle: PyodideProxyHandle; + proxyHandle: ProxyHandle; setTerminalMethods: React.Dispatch>; terminalInterface: TerminalInterface; }) { @@ -41,13 +41,13 @@ export function Terminal({ const xTermRef = React.useRef(null); const [xTermReadline, setXTermReadline] = React.useState(); - const runCodeRef = React.useRef(async (command: string): Promise => {}); + const runCodeRef = React.useRef(async (command: string): Promise => ""); React.useEffect(() => { - runCodeRef.current = async (command: string): Promise => { - if (!pyodideProxyHandle.ready) return; - await pyodideProxyHandle.runCode(command); + runCodeRef.current = async (command: string) => { + if (!proxyHandle.ready) return ""; + return await proxyHandle.runCode(command) ?? ">>> "; }; - }, [pyodideProxyHandle]); + }, [proxyHandle]); const tabCompleteRef = React.useRef( async (command: string): Promise => { @@ -56,10 +56,10 @@ export function Terminal({ ); React.useEffect(() => { tabCompleteRef.current = async (command: string): Promise => { - if (!pyodideProxyHandle.ready) return []; - return await pyodideProxyHandle.tabComplete(command); + if (!proxyHandle.ready) return []; + return await proxyHandle.tabComplete(command); }; - }, [pyodideProxyHandle]); + }, [proxyHandle]); React.useEffect(() => { // Start up the terminal and populate our reference objects. @@ -91,7 +91,7 @@ export function Terminal({ fitAddon.fit(); - term.write("Starting Python...\r\n"); + term.write("Starting...\r\n"); // const term = $(containerRef.current).terminal(interpreter, { // greetings: "Starting Python...", // prompt: "", @@ -154,8 +154,8 @@ export function Terminal({ const runCodeInTerminal = async (command: string): Promise => { xTermReadline.println(command); - await runCodeRef.current(command); - xTermReadline.print(">>> "); + const prompt = await runCodeRef.current(command); + xTermReadline.print(prompt); }; setTerminalMethods({ @@ -166,24 +166,24 @@ export function Terminal({ // TODO: Make sure this doesn't run twice React.useEffect(() => { - if (!pyodideProxyHandle.ready) return; + if (!proxyHandle.ready) return; if (!xTermRef.current) return; xTermRef.current.write("\x1Bc"); - function readLine() { + function readLine(prompt: string) { if (!xTermReadline) return; - xTermReadline.read(">>> ").then(processLine); + xTermReadline.read(prompt).then(processLine); } async function processLine(text: string) { if (!xTermReadline) return; - await runCodeRef.current(text); - setTimeout(readLine); + const prompt = await runCodeRef.current(text); + setTimeout(() => readLine(prompt)); } - readLine(); - }, [pyodideProxyHandle.ready, xTermReadline]); + readLine(proxyHandle.engine === "webr" ? '> ' : ">>> "); + }, [proxyHandle, xTermReadline]); return
; } From 4147a988105bcb5eefefcc71c4da07f9b1c75577 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 25 Jul 2023 15:44:59 +0100 Subject: [PATCH 07/21] Set up Viewer for running webR httpuv server The `useEffect()` hook setting up the `runApp()` and `stopApp()` methods is tweaked so that the current hook runs only when the Wasm engine is Pyodide. A second `useEffect()` hook is added for setting up the methods to start an R shiny server with webR. --- src/Components/App.tsx | 8 +-- src/Components/Viewer.tsx | 115 +++++++++++++++++++++++++++++++++----- src/hooks/useWebR.tsx | 9 +++ 3 files changed, 114 insertions(+), 18 deletions(-) diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 2d944934..bacfdc7f 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -353,7 +353,7 @@ export function App({ terminalInterface={terminalInterface} /> @@ -390,7 +390,7 @@ export function App({ terminalInterface={terminalInterface} /> @@ -476,7 +476,7 @@ export function App({ /> @@ -496,7 +496,7 @@ export function App({ }} > diff --git a/src/Components/Viewer.tsx b/src/Components/Viewer.tsx index c1034252..120b44e7 100644 --- a/src/Components/Viewer.tsx +++ b/src/Components/Viewer.tsx @@ -1,5 +1,6 @@ -import { PyodideProxyHandle } from "../hooks/usePyodide"; +import { ProxyHandle } from "./App"; import { PyodideProxy } from "../pyodide-proxy"; +import { WebRProxy } from "../webr-proxy"; import * as utils from "../utils"; import { LoadingAnimation } from "./LoadingAnimation"; import "./Viewer.css"; @@ -21,8 +22,8 @@ export type ViewerMethods = // Register a unique app path with the service worker. When fetches in our // origin match against the app path, navigation should be proxied through -// the current window (eventually making its way to pyodide). -function setupAppProxyPath(pyodide: PyodideProxy): { +// the current window (eventually making its way to the Wasm engine). +function setupAppProxyPath(proxy: PyodideProxy | WebRProxy): { appName: string; urlPath: string; } { @@ -39,12 +40,12 @@ function setupAppProxyPath(pyodide: PyodideProxy): { // and will restart as needed. When the service worker shuts down, it will // lose the state that tells it how to proxy requests for `urlPath`, so when // it restarts, we need to re-register with the service worker. - createHttpRequestChannel(pyodide, appName, urlPath); + createHttpRequestChannel(proxy, appName, urlPath); // Listen for the service worker's restart messages and re-register. navigator.serviceWorker.addEventListener("message", (event) => { if (event.data.type === "serviceworkerStart") { - createHttpRequestChannel(pyodide, appName, urlPath); + createHttpRequestChannel(proxy, appName, urlPath); } }); @@ -53,7 +54,7 @@ function setupAppProxyPath(pyodide: PyodideProxy): { // Register the app path with the service worker function createHttpRequestChannel( - pyodide: PyodideProxy, + proxy: PyodideProxy | WebRProxy, appName: string, urlPath: string ): MessageChannel { @@ -67,7 +68,7 @@ function createHttpRequestChannel( httpRequestChannel.port1.addEventListener("message", (event) => { const msg = event.data; if (msg.type === "makeRequest") { - pyodide.makeRequest(msg.scope, appName, event.ports[0]); + proxy.makeRequest(msg.scope, appName, event.ports[0]); } }); httpRequestChannel.port1.start(); @@ -83,7 +84,7 @@ function createHttpRequestChannel( return httpRequestChannel; } -async function resetAppFrame( +async function resetPyAppFrame( pyodide: PyodideProxy, appName: string, appFrame: HTMLIFrameElement @@ -103,14 +104,29 @@ async function resetAppFrame( } } +async function resetRAppFrame( + webRProxy: WebRProxy, + appName: string, + appFrame: HTMLIFrameElement +): Promise { + // Reset the app iframe before shutting down the app, so that the user doesn't + // see the flash of gray indicating a closed session. + appFrame.src = ""; + + await webRProxy.runRAsync('.stop_app()'); + + // Pause for a bit before continuing. + await utils.sleep(200); +} + // ============================================================================= // Viewer component // ============================================================================= export function Viewer({ - pyodideProxyHandle, + proxyHandle, setViewerMethods, }: { - pyodideProxyHandle: PyodideProxyHandle; + proxyHandle: ProxyHandle; setViewerMethods: React.Dispatch>; }) { const viewerFrameRef = React.useRef(null); @@ -122,10 +138,81 @@ export function Viewer({ null ); + // Shiny for R + React.useEffect(() => { + if (!proxyHandle.shinyReady) return; + if (proxyHandle.engine !== "webr") return; + + const webRProxy = proxyHandle.webRProxy; + const appInfo = setupAppProxyPath(webRProxy); + + async function runApp(appCode: string | FileContent[]): Promise { + try { + if (!viewerFrameRef.current) + throw new Error("Viewer iframe is not yet initialized"); + + setAppRunningState("loading"); + + if (typeof appCode === "string") { + appCode = [ + { + name: "app.R", + content: appCode, + type: "text", + }, + ]; + } + + const appName = appInfo.appName; + const appDir = "/home/web_user/" + appName; + const shelter = await new webRProxy.webR.Shelter(); + const files = await new shelter.RList( + Object.fromEntries(appCode.map((file) => { return [file.name, file.content] })) + ) + try { + await webRProxy.runRAsync('.save_files(files, appDir)', { env: { files, appDir } }); + // This blocks in Shiny's runApp() + webRProxy.runCode(`.start_app("${appDir}")`); + } finally { + shelter.purge(); + } + viewerFrameRef.current.src = appInfo.urlPath; + setAppRunningState("running"); + } catch (e) { + setAppRunningState("errored"); + if (e instanceof Error) { + console.error(e.message); + setLastErrorMessage(e.message); + } else { + console.error(e); + } + } + } + + async function stopApp(): Promise { + if (!viewerFrameRef.current) return; + + await resetRAppFrame( + webRProxy, + appInfo.appName, + viewerFrameRef.current + ); + setAppRunningState("empty"); + } + + setViewerMethods({ + ready: true, + runApp, + stopApp, + }); + }, [proxyHandle.shinyReady]); + + // Shiny for Python React.useEffect(() => { - if (!pyodideProxyHandle.shinyReady) return; + if (!proxyHandle.shinyReady) return; + if (proxyHandle.engine !== "pyodide") return; - const pyodideproxy = pyodideProxyHandle.pyodide; + const pyodideproxy = proxyHandle.pyodide; const appInfo = setupAppProxyPath(pyodideproxy); async function runApp(appCode: string | FileContent[]): Promise { @@ -175,7 +262,7 @@ export function Viewer({ async function stopApp(): Promise { if (!viewerFrameRef.current) return; - await resetAppFrame( + await resetPyAppFrame( pyodideproxy, appInfo.appName, viewerFrameRef.current @@ -188,7 +275,7 @@ export function Viewer({ runApp, stopApp, }); - }, [pyodideProxyHandle.shinyReady]); + }, [proxyHandle.shinyReady]); return (
diff --git a/src/hooks/useWebR.tsx b/src/hooks/useWebR.tsx index 04faec63..e96ccb18 100644 --- a/src/hooks/useWebR.tsx +++ b/src/hooks/useWebR.tsx @@ -148,6 +148,15 @@ const load_r_pre = } )) +.save_files <- function (files, appDir) { + for (name in names(files)) { + filename <- file.path(appDir, name) + path <- dirname(filename) + dir.create(path, recursive = TRUE, showWarnings = FALSE) + writeLines(files[[name]], filename) + } +} + .stop_app <- function() { webr::eval_js(" chan.write({ From 6a066ab1baad837faa75bd7f32a2abe847cb79bf Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 25 Jul 2023 16:01:23 +0100 Subject: [PATCH 08/21] Detect Shiny for R code using file extensions --- src/Components/App.tsx | 8 ++++++-- src/Components/Editor.tsx | 12 +++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/Components/App.tsx b/src/Components/App.tsx index bacfdc7f..58cf40c0 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -344,7 +344,9 @@ export function App({ terminalMethods={terminalMethods} viewerMethods={viewerMethods} utilityMethods={utilityMethods} - runOnLoad={currentFiles.some((file) => file.name === "app.py")} + runOnLoad={currentFiles.some((file) => + file.name === "app.py" || file.name === "app.R" || file.name === "server.R" + )} /> file.name === "app.py")} + runOnLoad={currentFiles.some((file) => + file.name === "app.py" || file.name === "app.R" || file.name === "server.R" + )} /> { - setIsShinyApp(files.some((file) => file.name === "app.py")); + setIsShinyApp(files.some( + (f) => f.name === "app.py" || f.name === "app.R" || f.name === "server.R") + ); }, [files]); // =========================================================================== @@ -205,7 +207,9 @@ export default function Editor({ // run as an app, or as code. This has to happen on the first pass, before // state vars are set and available. It would be nice to consolidate the // two vars, but I haven't figured out how yet. - const isShinyCode = currentFilesFromApp.some((f) => f.name === "app.py"); + const isShinyCode = currentFilesFromApp.some( + (f) => f.name === "app.py" || f.name === 'app.R' || f.name === 'server.R' + ); if (isShinyCode) { await viewerMethods.runApp(currentFilesFromApp); } @@ -227,7 +231,9 @@ export default function Editor({ if (!terminalMethods.ready) return; (async () => { - const isShinyCode = currentFilesFromApp.some((f) => f.name === "app.py"); + const isShinyCode = currentFilesFromApp.some( + (f) => f.name === "app.py" || f.name === 'app.R' || f.name === 'server.R' + ); if (!isShinyCode) { // TODO: use activeFile instead of currentFilesFromApp? if (currentFilesFromApp[0].type === "text") { From 8910d78d271b496cc2beda0946b9c6c36c225e22 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 25 Jul 2023 16:05:59 +0100 Subject: [PATCH 09/21] Partially handle webR for OutputCell component --- src/Components/App.tsx | 2 +- src/Components/OutputCell.tsx | 34 ++++++++++++++++++++++++++++------ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 58cf40c0..39f3e7b9 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -444,7 +444,7 @@ export function App({ />
diff --git a/src/Components/OutputCell.tsx b/src/Components/OutputCell.tsx index 4738a0fe..29bcac7d 100644 --- a/src/Components/OutputCell.tsx +++ b/src/Components/OutputCell.tsx @@ -1,4 +1,4 @@ -import { PyodideProxyHandle } from "../hooks/usePyodide"; +import { ProxyHandle } from "./App"; import { ToHtmlResult } from "../pyodide-proxy"; import "./OutputCell.css"; import { TerminalMethods } from "./Terminal"; @@ -8,10 +8,10 @@ import * as React from "react"; // OutputCell component // ============================================================================= export function OutputCell({ - pyodideProxyHandle, + proxyHandle, setTerminalMethods, }: { - pyodideProxyHandle: PyodideProxyHandle; + proxyHandle: ProxyHandle; setTerminalMethods: React.Dispatch>; }) { const [content, setContent] = React.useState({ @@ -21,10 +21,11 @@ export function OutputCell({ React.useEffect(() => { const runCodeInTerminal = async (command: string): Promise => { - if (!pyodideProxyHandle.ready) return; + if (!proxyHandle.ready) return; + if (proxyHandle.engine !== "pyodide") return; try { - const result = await pyodideProxyHandle.pyodide.runPyAsync(command, { + const result = await proxyHandle.pyodide.runPyAsync(command, { returnResult: "to_html", printResult: false, }); @@ -39,7 +40,28 @@ export function OutputCell({ ready: true, runCodeInTerminal, }); - }, [setTerminalMethods, pyodideProxyHandle]); + }, [setTerminalMethods, proxyHandle]); + + React.useEffect(() => { + const runCodeInTerminal = async (command: string): Promise => { + if (!proxyHandle.ready) return; + if (proxyHandle.engine !== "webr") return; + + try { + // TODO: Better convert output of runRAsync into HTML format + const result = await proxyHandle.webRProxy.runRAsync(command); + const output = JSON.stringify(await result.toJs()); + setContent({ type: "text", value: output }); + } catch (e) { + setContent({ type: "text", value: (e as Error).message }); + } + }; + + setTerminalMethods({ + ready: true, + runCodeInTerminal, + }); + }, [setTerminalMethods, proxyHandle]); return (
From adc3f321d7bbe36d13830ca968c8c02dd76109c4 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Wed, 26 Jul 2023 09:26:32 +0100 Subject: [PATCH 10/21] Handle interrupting R code using Ctrl-C --- src/Components/Terminal.tsx | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/Components/Terminal.tsx b/src/Components/Terminal.tsx index a1e58054..ce83f414 100644 --- a/src/Components/Terminal.tsx +++ b/src/Components/Terminal.tsx @@ -182,6 +182,20 @@ export function Terminal({ setTimeout(() => readLine(prompt)); } + // Handle ctrl-c above xterm-readline so that blocking R code or Shiny apps + // executed by the Editor can also be interrupted + function handleInterrupt(event: KeyboardEvent) { + if (!xTermRef.current) return; + if (!proxyHandle.ready) return; + if (proxyHandle.engine !== "webr") return; + if (event.key === 'c' && event.ctrlKey) { + xTermRef.current.write("^C"); + proxyHandle.interrupt(); + event.stopPropagation(); + } + } + containerRef.current!.addEventListener('keydown', handleInterrupt, true); + readLine(proxyHandle.engine === "webr" ? '> ' : ">>> "); }, [proxyHandle, xTermReadline]); From 0870759fe241c3fbd4a2ce2764b8ebd4b548357d Mon Sep 17 00:00:00 2001 From: George Stagg Date: Wed, 26 Jul 2023 10:46:18 +0100 Subject: [PATCH 11/21] Setup examples for R and Python --- examples/index.json | 26 +++++ examples/{ => python}/app_with_plot/about.txt | 0 examples/{ => python}/app_with_plot/app.py | 0 examples/{ => python}/basic_app/about.txt | 0 examples/{ => python}/basic_app/app.py | 0 examples/{ => python}/camera/about.txt | 0 examples/{ => python}/camera/app.py | 0 examples/{ => python}/cpuinfo/about.txt | 0 examples/{ => python}/cpuinfo/app.py | 0 examples/{ => python}/cpuinfo/fakepsutil.py | 0 .../{ => python}/extra_packages/about.txt | 0 examples/{ => python}/extra_packages/app.py | 0 .../extra_packages/requirements.txt | 0 examples/{ => python}/fetch/about.txt | 0 examples/{ => python}/fetch/app.py | 0 examples/{ => python}/fetch/download.py | 0 examples/{ => python}/file_download/about.txt | 0 examples/{ => python}/file_download/app.py | 0 .../{ => python}/file_download/mtcars.csv | 0 examples/{ => python}/file_upload/about.txt | 0 examples/{ => python}/file_upload/app.py | 0 examples/{ => python}/hello_world/about.txt | 0 examples/{ => python}/hello_world/hello.py | 0 .../{ => python}/input_checkbox/about.txt | 0 examples/{ => python}/input_checkbox/app.py | 0 .../input_checkbox_group/about.txt | 0 .../{ => python}/input_checkbox_group/app.py | 0 examples/{ => python}/input_date/about.txt | 0 examples/{ => python}/input_date/app.py | 0 .../{ => python}/input_date_range/about.txt | 0 examples/{ => python}/input_date_range/app.py | 0 examples/{ => python}/input_numeric/about.txt | 0 examples/{ => python}/input_numeric/app.py | 0 .../{ => python}/input_password/about.txt | 0 examples/{ => python}/input_password/app.py | 0 examples/{ => python}/input_radio/about.txt | 0 examples/{ => python}/input_radio/app.py | 0 examples/{ => python}/input_select/about.txt | 0 examples/{ => python}/input_select/app.py | 0 examples/{ => python}/input_slider/about.txt | 0 examples/{ => python}/input_slider/app.py | 0 examples/{ => python}/input_switch/about.txt | 0 examples/{ => python}/input_switch/app.py | 0 examples/{ => python}/input_text/about.txt | 0 examples/{ => python}/input_text/app.py | 0 .../{ => python}/input_text_area/about.txt | 0 examples/{ => python}/input_text_area/app.py | 0 examples/{ => python}/input_update/about.txt | 0 examples/{ => python}/input_update/app.py | 0 examples/{ => python}/insert_ui/about.txt | 0 examples/{ => python}/insert_ui/app.py | 0 examples/{ => python}/ipyleaflet/about.txt | 0 examples/{ => python}/ipyleaflet/app.py | 0 examples/{ => python}/ipywidgets/about.txt | 0 examples/{ => python}/ipywidgets/app.py | 0 .../{ => python}/layout_sidebar/about.txt | 0 examples/{ => python}/layout_sidebar/app.py | 0 .../{ => python}/layout_two_column/about.txt | 0 .../{ => python}/layout_two_column/app.py | 0 examples/{ => python}/modules/about.txt | 0 examples/{ => python}/modules/app.py | 0 .../multiple_source_files/about.txt | 0 .../{ => python}/multiple_source_files/app.py | 0 .../multiple_source_files/utils.py | 0 examples/{ => python}/orbit/about.txt | 0 examples/{ => python}/orbit/app.py | 0 examples/{ => python}/orbit/body.py | 0 examples/{ => python}/orbit/requirements.txt | 0 examples/{ => python}/orbit/simulation.py | 0 examples/{ => python}/orbit/www/coords.png | Bin .../output_data_frame_grid/about.txt | 0 .../output_data_frame_grid/app.py | 0 examples/{ => python}/output_plot/about.txt | 0 examples/{ => python}/output_plot/app.py | 0 examples/{ => python}/output_table/about.txt | 0 examples/{ => python}/output_table/app.py | 0 examples/{ => python}/output_text/about.txt | 0 examples/{ => python}/output_text/app.py | 0 .../output_text_verbatim/about.txt | 0 .../{ => python}/output_text_verbatim/app.py | 0 examples/{ => python}/output_ui/about.txt | 0 examples/{ => python}/output_ui/app.py | 0 .../plot_interact_basic/about.txt | 0 .../{ => python}/plot_interact_basic/app.py | 0 .../plot_interact_basic/mtcars.csv | 0 .../plot_interact_exclude/about.txt | 0 .../{ => python}/plot_interact_exclude/app.py | 0 .../plot_interact_exclude/mtcars.csv | 0 .../plot_interact_select/about.txt | 0 .../{ => python}/plot_interact_select/app.py | 0 .../plot_interact_select/mtcars.csv | 0 examples/{ => python}/plotly/about.txt | 0 examples/{ => python}/plotly/app.py | 0 examples/{ => python}/plotly/requirements.txt | 0 examples/{ => python}/reactive_calc/about.txt | 0 examples/{ => python}/reactive_calc/app.py | 0 .../{ => python}/reactive_effect/about.txt | 0 examples/{ => python}/reactive_effect/app.py | 0 .../{ => python}/reactive_event/about.txt | 0 examples/{ => python}/reactive_event/app.py | 0 .../{ => python}/reactive_value/about.txt | 0 examples/{ => python}/reactive_value/app.py | 0 .../read_local_csv_file/about.txt | 0 .../{ => python}/read_local_csv_file/app.py | 0 .../read_local_csv_file/mtcars.csv | 0 .../{ => python}/regularization/about.txt | 0 examples/{ => python}/regularization/app.py | 0 .../{ => python}/regularization/compare.py | 0 examples/{ => python}/shinyswatch/about.txt | 0 examples/{ => python}/shinyswatch/app.py | 0 .../{ => python}/shinyswatch/requirements.txt | 0 .../{ => python}/static_content/about.txt | 0 examples/{ => python}/static_content/app.py | 0 .../{ => python}/static_content/www/logo.png | Bin examples/{ => python}/wordle/about.txt | 0 examples/{ => python}/wordle/app.py | 0 examples/{ => python}/wordle/style.css | 0 examples/{ => python}/wordle/words.py | 0 examples/r/001-hello/DESCRIPTION | 7 ++ examples/r/001-hello/Readme.md | 3 + examples/r/001-hello/about.txt | 1 + examples/r/001-hello/app.R | 59 ++++++++++ examples/r/002-text/DESCRIPTION | 8 ++ examples/r/002-text/Readme.md | 1 + examples/r/002-text/about.txt | 1 + examples/r/002-text/app.R | 64 +++++++++++ examples/r/003-reactivity/DESCRIPTION | 7 ++ examples/r/003-reactivity/Readme.md | 5 + examples/r/003-reactivity/about.txt | 1 + examples/r/003-reactivity/app.R | 102 ++++++++++++++++++ examples/r/004-mpg/DESCRIPTION | 7 ++ examples/r/004-mpg/Readme.md | 4 + examples/r/004-mpg/about.txt | 1 + examples/r/004-mpg/app.R | 75 +++++++++++++ examples/r/005-sliders/DESCRIPTION | 7 ++ examples/r/005-sliders/Readme.md | 3 + examples/r/005-sliders/about.txt | 1 + examples/r/005-sliders/app.R | 86 +++++++++++++++ examples/r/006-tabsets/DESCRIPTION | 7 ++ examples/r/006-tabsets/Readme.md | 9 ++ examples/r/006-tabsets/about.txt | 1 + examples/r/006-tabsets/app.R | 92 ++++++++++++++++ examples/r/007-widgets/DESCRIPTION | 7 ++ examples/r/007-widgets/Readme.md | 1 + examples/r/007-widgets/about.txt | 1 + examples/r/007-widgets/app.R | 82 ++++++++++++++ examples/r/008-html/DESCRIPTION | 7 ++ examples/r/008-html/Readme.md | 1 + examples/r/008-html/about.txt | 1 + examples/r/008-html/app.R | 47 ++++++++ examples/r/008-html/www/index.html | 41 +++++++ examples/r/009-upload/DESCRIPTION | 7 ++ examples/r/009-upload/Readme.md | 3 + examples/r/009-upload/about.txt | 1 + examples/r/009-upload/app.R | 102 ++++++++++++++++++ examples/r/010-download/DESCRIPTION | 7 ++ examples/r/010-download/Readme.md | 2 + examples/r/010-download/about.txt | 1 + examples/r/010-download/app.R | 70 ++++++++++++ examples/r/011-timer/DESCRIPTION | 7 ++ examples/r/011-timer/Readme.md | 4 + examples/r/011-timer/about.txt | 1 + examples/r/011-timer/app.R | 21 ++++ 163 files changed, 992 insertions(+) rename examples/{ => python}/app_with_plot/about.txt (100%) rename examples/{ => python}/app_with_plot/app.py (100%) rename examples/{ => python}/basic_app/about.txt (100%) rename examples/{ => python}/basic_app/app.py (100%) rename examples/{ => python}/camera/about.txt (100%) rename examples/{ => python}/camera/app.py (100%) rename examples/{ => python}/cpuinfo/about.txt (100%) rename examples/{ => python}/cpuinfo/app.py (100%) rename examples/{ => python}/cpuinfo/fakepsutil.py (100%) rename examples/{ => python}/extra_packages/about.txt (100%) rename examples/{ => python}/extra_packages/app.py (100%) rename examples/{ => python}/extra_packages/requirements.txt (100%) rename examples/{ => python}/fetch/about.txt (100%) rename examples/{ => python}/fetch/app.py (100%) rename examples/{ => python}/fetch/download.py (100%) rename examples/{ => python}/file_download/about.txt (100%) rename examples/{ => python}/file_download/app.py (100%) rename examples/{ => python}/file_download/mtcars.csv (100%) rename examples/{ => python}/file_upload/about.txt (100%) rename examples/{ => python}/file_upload/app.py (100%) rename examples/{ => python}/hello_world/about.txt (100%) rename examples/{ => python}/hello_world/hello.py (100%) rename examples/{ => python}/input_checkbox/about.txt (100%) rename examples/{ => python}/input_checkbox/app.py (100%) rename examples/{ => python}/input_checkbox_group/about.txt (100%) rename examples/{ => python}/input_checkbox_group/app.py (100%) rename examples/{ => python}/input_date/about.txt (100%) rename examples/{ => python}/input_date/app.py (100%) rename examples/{ => python}/input_date_range/about.txt (100%) rename examples/{ => python}/input_date_range/app.py (100%) rename examples/{ => python}/input_numeric/about.txt (100%) rename examples/{ => python}/input_numeric/app.py (100%) rename examples/{ => python}/input_password/about.txt (100%) rename examples/{ => python}/input_password/app.py (100%) rename examples/{ => python}/input_radio/about.txt (100%) rename examples/{ => python}/input_radio/app.py (100%) rename examples/{ => python}/input_select/about.txt (100%) rename examples/{ => python}/input_select/app.py (100%) rename examples/{ => python}/input_slider/about.txt (100%) rename examples/{ => python}/input_slider/app.py (100%) rename examples/{ => python}/input_switch/about.txt (100%) rename examples/{ => python}/input_switch/app.py (100%) rename examples/{ => python}/input_text/about.txt (100%) rename examples/{ => python}/input_text/app.py (100%) rename examples/{ => python}/input_text_area/about.txt (100%) rename examples/{ => python}/input_text_area/app.py (100%) rename examples/{ => python}/input_update/about.txt (100%) rename examples/{ => python}/input_update/app.py (100%) rename examples/{ => python}/insert_ui/about.txt (100%) rename examples/{ => python}/insert_ui/app.py (100%) rename examples/{ => python}/ipyleaflet/about.txt (100%) rename examples/{ => python}/ipyleaflet/app.py (100%) rename examples/{ => python}/ipywidgets/about.txt (100%) rename examples/{ => python}/ipywidgets/app.py (100%) rename examples/{ => python}/layout_sidebar/about.txt (100%) rename examples/{ => python}/layout_sidebar/app.py (100%) rename examples/{ => python}/layout_two_column/about.txt (100%) rename examples/{ => python}/layout_two_column/app.py (100%) rename examples/{ => python}/modules/about.txt (100%) rename examples/{ => python}/modules/app.py (100%) rename examples/{ => python}/multiple_source_files/about.txt (100%) rename examples/{ => python}/multiple_source_files/app.py (100%) rename examples/{ => python}/multiple_source_files/utils.py (100%) rename examples/{ => python}/orbit/about.txt (100%) rename examples/{ => python}/orbit/app.py (100%) rename examples/{ => python}/orbit/body.py (100%) rename examples/{ => python}/orbit/requirements.txt (100%) rename examples/{ => python}/orbit/simulation.py (100%) rename examples/{ => python}/orbit/www/coords.png (100%) rename examples/{ => python}/output_data_frame_grid/about.txt (100%) rename examples/{ => python}/output_data_frame_grid/app.py (100%) rename examples/{ => python}/output_plot/about.txt (100%) rename examples/{ => python}/output_plot/app.py (100%) rename examples/{ => python}/output_table/about.txt (100%) rename examples/{ => python}/output_table/app.py (100%) rename examples/{ => python}/output_text/about.txt (100%) rename examples/{ => python}/output_text/app.py (100%) rename examples/{ => python}/output_text_verbatim/about.txt (100%) rename examples/{ => python}/output_text_verbatim/app.py (100%) rename examples/{ => python}/output_ui/about.txt (100%) rename examples/{ => python}/output_ui/app.py (100%) rename examples/{ => python}/plot_interact_basic/about.txt (100%) rename examples/{ => python}/plot_interact_basic/app.py (100%) rename examples/{ => python}/plot_interact_basic/mtcars.csv (100%) rename examples/{ => python}/plot_interact_exclude/about.txt (100%) rename examples/{ => python}/plot_interact_exclude/app.py (100%) rename examples/{ => python}/plot_interact_exclude/mtcars.csv (100%) rename examples/{ => python}/plot_interact_select/about.txt (100%) rename examples/{ => python}/plot_interact_select/app.py (100%) rename examples/{ => python}/plot_interact_select/mtcars.csv (100%) rename examples/{ => python}/plotly/about.txt (100%) rename examples/{ => python}/plotly/app.py (100%) rename examples/{ => python}/plotly/requirements.txt (100%) rename examples/{ => python}/reactive_calc/about.txt (100%) rename examples/{ => python}/reactive_calc/app.py (100%) rename examples/{ => python}/reactive_effect/about.txt (100%) rename examples/{ => python}/reactive_effect/app.py (100%) rename examples/{ => python}/reactive_event/about.txt (100%) rename examples/{ => python}/reactive_event/app.py (100%) rename examples/{ => python}/reactive_value/about.txt (100%) rename examples/{ => python}/reactive_value/app.py (100%) rename examples/{ => python}/read_local_csv_file/about.txt (100%) rename examples/{ => python}/read_local_csv_file/app.py (100%) rename examples/{ => python}/read_local_csv_file/mtcars.csv (100%) rename examples/{ => python}/regularization/about.txt (100%) rename examples/{ => python}/regularization/app.py (100%) rename examples/{ => python}/regularization/compare.py (100%) rename examples/{ => python}/shinyswatch/about.txt (100%) rename examples/{ => python}/shinyswatch/app.py (100%) rename examples/{ => python}/shinyswatch/requirements.txt (100%) rename examples/{ => python}/static_content/about.txt (100%) rename examples/{ => python}/static_content/app.py (100%) rename examples/{ => python}/static_content/www/logo.png (100%) rename examples/{ => python}/wordle/about.txt (100%) rename examples/{ => python}/wordle/app.py (100%) rename examples/{ => python}/wordle/style.css (100%) rename examples/{ => python}/wordle/words.py (100%) create mode 100644 examples/r/001-hello/DESCRIPTION create mode 100644 examples/r/001-hello/Readme.md create mode 100644 examples/r/001-hello/about.txt create mode 100644 examples/r/001-hello/app.R create mode 100644 examples/r/002-text/DESCRIPTION create mode 100644 examples/r/002-text/Readme.md create mode 100644 examples/r/002-text/about.txt create mode 100644 examples/r/002-text/app.R create mode 100644 examples/r/003-reactivity/DESCRIPTION create mode 100644 examples/r/003-reactivity/Readme.md create mode 100644 examples/r/003-reactivity/about.txt create mode 100644 examples/r/003-reactivity/app.R create mode 100644 examples/r/004-mpg/DESCRIPTION create mode 100644 examples/r/004-mpg/Readme.md create mode 100644 examples/r/004-mpg/about.txt create mode 100644 examples/r/004-mpg/app.R create mode 100644 examples/r/005-sliders/DESCRIPTION create mode 100644 examples/r/005-sliders/Readme.md create mode 100644 examples/r/005-sliders/about.txt create mode 100644 examples/r/005-sliders/app.R create mode 100644 examples/r/006-tabsets/DESCRIPTION create mode 100644 examples/r/006-tabsets/Readme.md create mode 100644 examples/r/006-tabsets/about.txt create mode 100644 examples/r/006-tabsets/app.R create mode 100644 examples/r/007-widgets/DESCRIPTION create mode 100644 examples/r/007-widgets/Readme.md create mode 100644 examples/r/007-widgets/about.txt create mode 100644 examples/r/007-widgets/app.R create mode 100644 examples/r/008-html/DESCRIPTION create mode 100644 examples/r/008-html/Readme.md create mode 100644 examples/r/008-html/about.txt create mode 100644 examples/r/008-html/app.R create mode 100644 examples/r/008-html/www/index.html create mode 100644 examples/r/009-upload/DESCRIPTION create mode 100644 examples/r/009-upload/Readme.md create mode 100644 examples/r/009-upload/about.txt create mode 100644 examples/r/009-upload/app.R create mode 100644 examples/r/010-download/DESCRIPTION create mode 100644 examples/r/010-download/Readme.md create mode 100644 examples/r/010-download/about.txt create mode 100644 examples/r/010-download/app.R create mode 100644 examples/r/011-timer/DESCRIPTION create mode 100644 examples/r/011-timer/Readme.md create mode 100644 examples/r/011-timer/about.txt create mode 100644 examples/r/011-timer/app.R diff --git a/examples/index.json b/examples/index.json index 8d7e2980..23304d95 100644 --- a/examples/index.json +++ b/examples/index.json @@ -1,10 +1,12 @@ [ { "category": "Basic", + "engine": "python", "apps": ["basic_app", "app_with_plot"] }, { "category": "Featured", + "engine": "python", "apps": [ "cpuinfo", "orbit", @@ -17,6 +19,7 @@ }, { "category": "Intermediate", + "engine": "python", "apps": [ "multiple_source_files", "read_local_csv_file", @@ -33,6 +36,7 @@ }, { "category": "Inputs", + "engine": "python", "apps": [ "input_text", "input_numeric", @@ -50,6 +54,7 @@ }, { "category": "Outputs", + "engine": "python", "apps": [ "output_text", "output_text_verbatim", @@ -61,10 +66,12 @@ }, { "category": "Layout", + "engine": "python", "apps": ["shinyswatch", "layout_sidebar", "layout_two_column"] }, { "category": "Reactivity", + "engine": "python", "apps": [ "reactive_event", "reactive_effect", @@ -74,6 +81,7 @@ }, { "category": "Interactive plots", + "engine": "python", "apps": [ "plot_interact_basic", "plot_interact_select", @@ -82,6 +90,24 @@ }, { "category": "Non-Apps", + "engine": "python", "apps": ["hello_world"] + }, + { + "category": "R examples", + "engine": "r", + "apps": [ + "001-hello", + "002-text", + "003-reactivity", + "004-mpg", + "005-sliders", + "006-tabsets", + "007-widgets", + "008-html", + "009-upload", + "010-download", + "011-timer" + ] } ] diff --git a/examples/app_with_plot/about.txt b/examples/python/app_with_plot/about.txt similarity index 100% rename from examples/app_with_plot/about.txt rename to examples/python/app_with_plot/about.txt diff --git a/examples/app_with_plot/app.py b/examples/python/app_with_plot/app.py similarity index 100% rename from examples/app_with_plot/app.py rename to examples/python/app_with_plot/app.py diff --git a/examples/basic_app/about.txt b/examples/python/basic_app/about.txt similarity index 100% rename from examples/basic_app/about.txt rename to examples/python/basic_app/about.txt diff --git a/examples/basic_app/app.py b/examples/python/basic_app/app.py similarity index 100% rename from examples/basic_app/app.py rename to examples/python/basic_app/app.py diff --git a/examples/camera/about.txt b/examples/python/camera/about.txt similarity index 100% rename from examples/camera/about.txt rename to examples/python/camera/about.txt diff --git a/examples/camera/app.py b/examples/python/camera/app.py similarity index 100% rename from examples/camera/app.py rename to examples/python/camera/app.py diff --git a/examples/cpuinfo/about.txt b/examples/python/cpuinfo/about.txt similarity index 100% rename from examples/cpuinfo/about.txt rename to examples/python/cpuinfo/about.txt diff --git a/examples/cpuinfo/app.py b/examples/python/cpuinfo/app.py similarity index 100% rename from examples/cpuinfo/app.py rename to examples/python/cpuinfo/app.py diff --git a/examples/cpuinfo/fakepsutil.py b/examples/python/cpuinfo/fakepsutil.py similarity index 100% rename from examples/cpuinfo/fakepsutil.py rename to examples/python/cpuinfo/fakepsutil.py diff --git a/examples/extra_packages/about.txt b/examples/python/extra_packages/about.txt similarity index 100% rename from examples/extra_packages/about.txt rename to examples/python/extra_packages/about.txt diff --git a/examples/extra_packages/app.py b/examples/python/extra_packages/app.py similarity index 100% rename from examples/extra_packages/app.py rename to examples/python/extra_packages/app.py diff --git a/examples/extra_packages/requirements.txt b/examples/python/extra_packages/requirements.txt similarity index 100% rename from examples/extra_packages/requirements.txt rename to examples/python/extra_packages/requirements.txt diff --git a/examples/fetch/about.txt b/examples/python/fetch/about.txt similarity index 100% rename from examples/fetch/about.txt rename to examples/python/fetch/about.txt diff --git a/examples/fetch/app.py b/examples/python/fetch/app.py similarity index 100% rename from examples/fetch/app.py rename to examples/python/fetch/app.py diff --git a/examples/fetch/download.py b/examples/python/fetch/download.py similarity index 100% rename from examples/fetch/download.py rename to examples/python/fetch/download.py diff --git a/examples/file_download/about.txt b/examples/python/file_download/about.txt similarity index 100% rename from examples/file_download/about.txt rename to examples/python/file_download/about.txt diff --git a/examples/file_download/app.py b/examples/python/file_download/app.py similarity index 100% rename from examples/file_download/app.py rename to examples/python/file_download/app.py diff --git a/examples/file_download/mtcars.csv b/examples/python/file_download/mtcars.csv similarity index 100% rename from examples/file_download/mtcars.csv rename to examples/python/file_download/mtcars.csv diff --git a/examples/file_upload/about.txt b/examples/python/file_upload/about.txt similarity index 100% rename from examples/file_upload/about.txt rename to examples/python/file_upload/about.txt diff --git a/examples/file_upload/app.py b/examples/python/file_upload/app.py similarity index 100% rename from examples/file_upload/app.py rename to examples/python/file_upload/app.py diff --git a/examples/hello_world/about.txt b/examples/python/hello_world/about.txt similarity index 100% rename from examples/hello_world/about.txt rename to examples/python/hello_world/about.txt diff --git a/examples/hello_world/hello.py b/examples/python/hello_world/hello.py similarity index 100% rename from examples/hello_world/hello.py rename to examples/python/hello_world/hello.py diff --git a/examples/input_checkbox/about.txt b/examples/python/input_checkbox/about.txt similarity index 100% rename from examples/input_checkbox/about.txt rename to examples/python/input_checkbox/about.txt diff --git a/examples/input_checkbox/app.py b/examples/python/input_checkbox/app.py similarity index 100% rename from examples/input_checkbox/app.py rename to examples/python/input_checkbox/app.py diff --git a/examples/input_checkbox_group/about.txt b/examples/python/input_checkbox_group/about.txt similarity index 100% rename from examples/input_checkbox_group/about.txt rename to examples/python/input_checkbox_group/about.txt diff --git a/examples/input_checkbox_group/app.py b/examples/python/input_checkbox_group/app.py similarity index 100% rename from examples/input_checkbox_group/app.py rename to examples/python/input_checkbox_group/app.py diff --git a/examples/input_date/about.txt b/examples/python/input_date/about.txt similarity index 100% rename from examples/input_date/about.txt rename to examples/python/input_date/about.txt diff --git a/examples/input_date/app.py b/examples/python/input_date/app.py similarity index 100% rename from examples/input_date/app.py rename to examples/python/input_date/app.py diff --git a/examples/input_date_range/about.txt b/examples/python/input_date_range/about.txt similarity index 100% rename from examples/input_date_range/about.txt rename to examples/python/input_date_range/about.txt diff --git a/examples/input_date_range/app.py b/examples/python/input_date_range/app.py similarity index 100% rename from examples/input_date_range/app.py rename to examples/python/input_date_range/app.py diff --git a/examples/input_numeric/about.txt b/examples/python/input_numeric/about.txt similarity index 100% rename from examples/input_numeric/about.txt rename to examples/python/input_numeric/about.txt diff --git a/examples/input_numeric/app.py b/examples/python/input_numeric/app.py similarity index 100% rename from examples/input_numeric/app.py rename to examples/python/input_numeric/app.py diff --git a/examples/input_password/about.txt b/examples/python/input_password/about.txt similarity index 100% rename from examples/input_password/about.txt rename to examples/python/input_password/about.txt diff --git a/examples/input_password/app.py b/examples/python/input_password/app.py similarity index 100% rename from examples/input_password/app.py rename to examples/python/input_password/app.py diff --git a/examples/input_radio/about.txt b/examples/python/input_radio/about.txt similarity index 100% rename from examples/input_radio/about.txt rename to examples/python/input_radio/about.txt diff --git a/examples/input_radio/app.py b/examples/python/input_radio/app.py similarity index 100% rename from examples/input_radio/app.py rename to examples/python/input_radio/app.py diff --git a/examples/input_select/about.txt b/examples/python/input_select/about.txt similarity index 100% rename from examples/input_select/about.txt rename to examples/python/input_select/about.txt diff --git a/examples/input_select/app.py b/examples/python/input_select/app.py similarity index 100% rename from examples/input_select/app.py rename to examples/python/input_select/app.py diff --git a/examples/input_slider/about.txt b/examples/python/input_slider/about.txt similarity index 100% rename from examples/input_slider/about.txt rename to examples/python/input_slider/about.txt diff --git a/examples/input_slider/app.py b/examples/python/input_slider/app.py similarity index 100% rename from examples/input_slider/app.py rename to examples/python/input_slider/app.py diff --git a/examples/input_switch/about.txt b/examples/python/input_switch/about.txt similarity index 100% rename from examples/input_switch/about.txt rename to examples/python/input_switch/about.txt diff --git a/examples/input_switch/app.py b/examples/python/input_switch/app.py similarity index 100% rename from examples/input_switch/app.py rename to examples/python/input_switch/app.py diff --git a/examples/input_text/about.txt b/examples/python/input_text/about.txt similarity index 100% rename from examples/input_text/about.txt rename to examples/python/input_text/about.txt diff --git a/examples/input_text/app.py b/examples/python/input_text/app.py similarity index 100% rename from examples/input_text/app.py rename to examples/python/input_text/app.py diff --git a/examples/input_text_area/about.txt b/examples/python/input_text_area/about.txt similarity index 100% rename from examples/input_text_area/about.txt rename to examples/python/input_text_area/about.txt diff --git a/examples/input_text_area/app.py b/examples/python/input_text_area/app.py similarity index 100% rename from examples/input_text_area/app.py rename to examples/python/input_text_area/app.py diff --git a/examples/input_update/about.txt b/examples/python/input_update/about.txt similarity index 100% rename from examples/input_update/about.txt rename to examples/python/input_update/about.txt diff --git a/examples/input_update/app.py b/examples/python/input_update/app.py similarity index 100% rename from examples/input_update/app.py rename to examples/python/input_update/app.py diff --git a/examples/insert_ui/about.txt b/examples/python/insert_ui/about.txt similarity index 100% rename from examples/insert_ui/about.txt rename to examples/python/insert_ui/about.txt diff --git a/examples/insert_ui/app.py b/examples/python/insert_ui/app.py similarity index 100% rename from examples/insert_ui/app.py rename to examples/python/insert_ui/app.py diff --git a/examples/ipyleaflet/about.txt b/examples/python/ipyleaflet/about.txt similarity index 100% rename from examples/ipyleaflet/about.txt rename to examples/python/ipyleaflet/about.txt diff --git a/examples/ipyleaflet/app.py b/examples/python/ipyleaflet/app.py similarity index 100% rename from examples/ipyleaflet/app.py rename to examples/python/ipyleaflet/app.py diff --git a/examples/ipywidgets/about.txt b/examples/python/ipywidgets/about.txt similarity index 100% rename from examples/ipywidgets/about.txt rename to examples/python/ipywidgets/about.txt diff --git a/examples/ipywidgets/app.py b/examples/python/ipywidgets/app.py similarity index 100% rename from examples/ipywidgets/app.py rename to examples/python/ipywidgets/app.py diff --git a/examples/layout_sidebar/about.txt b/examples/python/layout_sidebar/about.txt similarity index 100% rename from examples/layout_sidebar/about.txt rename to examples/python/layout_sidebar/about.txt diff --git a/examples/layout_sidebar/app.py b/examples/python/layout_sidebar/app.py similarity index 100% rename from examples/layout_sidebar/app.py rename to examples/python/layout_sidebar/app.py diff --git a/examples/layout_two_column/about.txt b/examples/python/layout_two_column/about.txt similarity index 100% rename from examples/layout_two_column/about.txt rename to examples/python/layout_two_column/about.txt diff --git a/examples/layout_two_column/app.py b/examples/python/layout_two_column/app.py similarity index 100% rename from examples/layout_two_column/app.py rename to examples/python/layout_two_column/app.py diff --git a/examples/modules/about.txt b/examples/python/modules/about.txt similarity index 100% rename from examples/modules/about.txt rename to examples/python/modules/about.txt diff --git a/examples/modules/app.py b/examples/python/modules/app.py similarity index 100% rename from examples/modules/app.py rename to examples/python/modules/app.py diff --git a/examples/multiple_source_files/about.txt b/examples/python/multiple_source_files/about.txt similarity index 100% rename from examples/multiple_source_files/about.txt rename to examples/python/multiple_source_files/about.txt diff --git a/examples/multiple_source_files/app.py b/examples/python/multiple_source_files/app.py similarity index 100% rename from examples/multiple_source_files/app.py rename to examples/python/multiple_source_files/app.py diff --git a/examples/multiple_source_files/utils.py b/examples/python/multiple_source_files/utils.py similarity index 100% rename from examples/multiple_source_files/utils.py rename to examples/python/multiple_source_files/utils.py diff --git a/examples/orbit/about.txt b/examples/python/orbit/about.txt similarity index 100% rename from examples/orbit/about.txt rename to examples/python/orbit/about.txt diff --git a/examples/orbit/app.py b/examples/python/orbit/app.py similarity index 100% rename from examples/orbit/app.py rename to examples/python/orbit/app.py diff --git a/examples/orbit/body.py b/examples/python/orbit/body.py similarity index 100% rename from examples/orbit/body.py rename to examples/python/orbit/body.py diff --git a/examples/orbit/requirements.txt b/examples/python/orbit/requirements.txt similarity index 100% rename from examples/orbit/requirements.txt rename to examples/python/orbit/requirements.txt diff --git a/examples/orbit/simulation.py b/examples/python/orbit/simulation.py similarity index 100% rename from examples/orbit/simulation.py rename to examples/python/orbit/simulation.py diff --git a/examples/orbit/www/coords.png b/examples/python/orbit/www/coords.png similarity index 100% rename from examples/orbit/www/coords.png rename to examples/python/orbit/www/coords.png diff --git a/examples/output_data_frame_grid/about.txt b/examples/python/output_data_frame_grid/about.txt similarity index 100% rename from examples/output_data_frame_grid/about.txt rename to examples/python/output_data_frame_grid/about.txt diff --git a/examples/output_data_frame_grid/app.py b/examples/python/output_data_frame_grid/app.py similarity index 100% rename from examples/output_data_frame_grid/app.py rename to examples/python/output_data_frame_grid/app.py diff --git a/examples/output_plot/about.txt b/examples/python/output_plot/about.txt similarity index 100% rename from examples/output_plot/about.txt rename to examples/python/output_plot/about.txt diff --git a/examples/output_plot/app.py b/examples/python/output_plot/app.py similarity index 100% rename from examples/output_plot/app.py rename to examples/python/output_plot/app.py diff --git a/examples/output_table/about.txt b/examples/python/output_table/about.txt similarity index 100% rename from examples/output_table/about.txt rename to examples/python/output_table/about.txt diff --git a/examples/output_table/app.py b/examples/python/output_table/app.py similarity index 100% rename from examples/output_table/app.py rename to examples/python/output_table/app.py diff --git a/examples/output_text/about.txt b/examples/python/output_text/about.txt similarity index 100% rename from examples/output_text/about.txt rename to examples/python/output_text/about.txt diff --git a/examples/output_text/app.py b/examples/python/output_text/app.py similarity index 100% rename from examples/output_text/app.py rename to examples/python/output_text/app.py diff --git a/examples/output_text_verbatim/about.txt b/examples/python/output_text_verbatim/about.txt similarity index 100% rename from examples/output_text_verbatim/about.txt rename to examples/python/output_text_verbatim/about.txt diff --git a/examples/output_text_verbatim/app.py b/examples/python/output_text_verbatim/app.py similarity index 100% rename from examples/output_text_verbatim/app.py rename to examples/python/output_text_verbatim/app.py diff --git a/examples/output_ui/about.txt b/examples/python/output_ui/about.txt similarity index 100% rename from examples/output_ui/about.txt rename to examples/python/output_ui/about.txt diff --git a/examples/output_ui/app.py b/examples/python/output_ui/app.py similarity index 100% rename from examples/output_ui/app.py rename to examples/python/output_ui/app.py diff --git a/examples/plot_interact_basic/about.txt b/examples/python/plot_interact_basic/about.txt similarity index 100% rename from examples/plot_interact_basic/about.txt rename to examples/python/plot_interact_basic/about.txt diff --git a/examples/plot_interact_basic/app.py b/examples/python/plot_interact_basic/app.py similarity index 100% rename from examples/plot_interact_basic/app.py rename to examples/python/plot_interact_basic/app.py diff --git a/examples/plot_interact_basic/mtcars.csv b/examples/python/plot_interact_basic/mtcars.csv similarity index 100% rename from examples/plot_interact_basic/mtcars.csv rename to examples/python/plot_interact_basic/mtcars.csv diff --git a/examples/plot_interact_exclude/about.txt b/examples/python/plot_interact_exclude/about.txt similarity index 100% rename from examples/plot_interact_exclude/about.txt rename to examples/python/plot_interact_exclude/about.txt diff --git a/examples/plot_interact_exclude/app.py b/examples/python/plot_interact_exclude/app.py similarity index 100% rename from examples/plot_interact_exclude/app.py rename to examples/python/plot_interact_exclude/app.py diff --git a/examples/plot_interact_exclude/mtcars.csv b/examples/python/plot_interact_exclude/mtcars.csv similarity index 100% rename from examples/plot_interact_exclude/mtcars.csv rename to examples/python/plot_interact_exclude/mtcars.csv diff --git a/examples/plot_interact_select/about.txt b/examples/python/plot_interact_select/about.txt similarity index 100% rename from examples/plot_interact_select/about.txt rename to examples/python/plot_interact_select/about.txt diff --git a/examples/plot_interact_select/app.py b/examples/python/plot_interact_select/app.py similarity index 100% rename from examples/plot_interact_select/app.py rename to examples/python/plot_interact_select/app.py diff --git a/examples/plot_interact_select/mtcars.csv b/examples/python/plot_interact_select/mtcars.csv similarity index 100% rename from examples/plot_interact_select/mtcars.csv rename to examples/python/plot_interact_select/mtcars.csv diff --git a/examples/plotly/about.txt b/examples/python/plotly/about.txt similarity index 100% rename from examples/plotly/about.txt rename to examples/python/plotly/about.txt diff --git a/examples/plotly/app.py b/examples/python/plotly/app.py similarity index 100% rename from examples/plotly/app.py rename to examples/python/plotly/app.py diff --git a/examples/plotly/requirements.txt b/examples/python/plotly/requirements.txt similarity index 100% rename from examples/plotly/requirements.txt rename to examples/python/plotly/requirements.txt diff --git a/examples/reactive_calc/about.txt b/examples/python/reactive_calc/about.txt similarity index 100% rename from examples/reactive_calc/about.txt rename to examples/python/reactive_calc/about.txt diff --git a/examples/reactive_calc/app.py b/examples/python/reactive_calc/app.py similarity index 100% rename from examples/reactive_calc/app.py rename to examples/python/reactive_calc/app.py diff --git a/examples/reactive_effect/about.txt b/examples/python/reactive_effect/about.txt similarity index 100% rename from examples/reactive_effect/about.txt rename to examples/python/reactive_effect/about.txt diff --git a/examples/reactive_effect/app.py b/examples/python/reactive_effect/app.py similarity index 100% rename from examples/reactive_effect/app.py rename to examples/python/reactive_effect/app.py diff --git a/examples/reactive_event/about.txt b/examples/python/reactive_event/about.txt similarity index 100% rename from examples/reactive_event/about.txt rename to examples/python/reactive_event/about.txt diff --git a/examples/reactive_event/app.py b/examples/python/reactive_event/app.py similarity index 100% rename from examples/reactive_event/app.py rename to examples/python/reactive_event/app.py diff --git a/examples/reactive_value/about.txt b/examples/python/reactive_value/about.txt similarity index 100% rename from examples/reactive_value/about.txt rename to examples/python/reactive_value/about.txt diff --git a/examples/reactive_value/app.py b/examples/python/reactive_value/app.py similarity index 100% rename from examples/reactive_value/app.py rename to examples/python/reactive_value/app.py diff --git a/examples/read_local_csv_file/about.txt b/examples/python/read_local_csv_file/about.txt similarity index 100% rename from examples/read_local_csv_file/about.txt rename to examples/python/read_local_csv_file/about.txt diff --git a/examples/read_local_csv_file/app.py b/examples/python/read_local_csv_file/app.py similarity index 100% rename from examples/read_local_csv_file/app.py rename to examples/python/read_local_csv_file/app.py diff --git a/examples/read_local_csv_file/mtcars.csv b/examples/python/read_local_csv_file/mtcars.csv similarity index 100% rename from examples/read_local_csv_file/mtcars.csv rename to examples/python/read_local_csv_file/mtcars.csv diff --git a/examples/regularization/about.txt b/examples/python/regularization/about.txt similarity index 100% rename from examples/regularization/about.txt rename to examples/python/regularization/about.txt diff --git a/examples/regularization/app.py b/examples/python/regularization/app.py similarity index 100% rename from examples/regularization/app.py rename to examples/python/regularization/app.py diff --git a/examples/regularization/compare.py b/examples/python/regularization/compare.py similarity index 100% rename from examples/regularization/compare.py rename to examples/python/regularization/compare.py diff --git a/examples/shinyswatch/about.txt b/examples/python/shinyswatch/about.txt similarity index 100% rename from examples/shinyswatch/about.txt rename to examples/python/shinyswatch/about.txt diff --git a/examples/shinyswatch/app.py b/examples/python/shinyswatch/app.py similarity index 100% rename from examples/shinyswatch/app.py rename to examples/python/shinyswatch/app.py diff --git a/examples/shinyswatch/requirements.txt b/examples/python/shinyswatch/requirements.txt similarity index 100% rename from examples/shinyswatch/requirements.txt rename to examples/python/shinyswatch/requirements.txt diff --git a/examples/static_content/about.txt b/examples/python/static_content/about.txt similarity index 100% rename from examples/static_content/about.txt rename to examples/python/static_content/about.txt diff --git a/examples/static_content/app.py b/examples/python/static_content/app.py similarity index 100% rename from examples/static_content/app.py rename to examples/python/static_content/app.py diff --git a/examples/static_content/www/logo.png b/examples/python/static_content/www/logo.png similarity index 100% rename from examples/static_content/www/logo.png rename to examples/python/static_content/www/logo.png diff --git a/examples/wordle/about.txt b/examples/python/wordle/about.txt similarity index 100% rename from examples/wordle/about.txt rename to examples/python/wordle/about.txt diff --git a/examples/wordle/app.py b/examples/python/wordle/app.py similarity index 100% rename from examples/wordle/app.py rename to examples/python/wordle/app.py diff --git a/examples/wordle/style.css b/examples/python/wordle/style.css similarity index 100% rename from examples/wordle/style.css rename to examples/python/wordle/style.css diff --git a/examples/wordle/words.py b/examples/python/wordle/words.py similarity index 100% rename from examples/wordle/words.py rename to examples/python/wordle/words.py diff --git a/examples/r/001-hello/DESCRIPTION b/examples/r/001-hello/DESCRIPTION new file mode 100644 index 00000000..fd1d5a52 --- /dev/null +++ b/examples/r/001-hello/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Hello Shiny! +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/001-hello/Readme.md b/examples/r/001-hello/Readme.md new file mode 100644 index 00000000..a2450acc --- /dev/null +++ b/examples/r/001-hello/Readme.md @@ -0,0 +1,3 @@ +This small Shiny application demonstrates Shiny's automatic UI updates. + +Move the *Number of bins* slider and notice how the `renderPlot` expression is automatically re-evaluated when its dependant, `input$bins`, changes, causing a histogram with a new number of bins to be rendered. diff --git a/examples/r/001-hello/about.txt b/examples/r/001-hello/about.txt new file mode 100644 index 00000000..1234d30c --- /dev/null +++ b/examples/r/001-hello/about.txt @@ -0,0 +1 @@ +Hello Shiny! diff --git a/examples/r/001-hello/app.R b/examples/r/001-hello/app.R new file mode 100644 index 00000000..887c5057 --- /dev/null +++ b/examples/r/001-hello/app.R @@ -0,0 +1,59 @@ +library(shiny) + +# Define UI for app that draws a histogram ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Hello Shiny!"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Slider for the number of bins ---- + sliderInput(inputId = "bins", + label = "Number of bins:", + min = 1, + max = 50, + value = 30) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Histogram ---- + plotOutput(outputId = "distPlot") + + ) + ) +) + +# Define server logic required to draw a histogram ---- +server <- function(input, output) { + + # Histogram of the Old Faithful Geyser Data ---- + # with requested number of bins + # This expression that generates a histogram is wrapped in a call + # to renderPlot to indicate that: + # + # 1. It is "reactive" and therefore should be automatically + # re-executed when inputs (input$bins) change + # 2. Its output type is a plot + output$distPlot <- renderPlot({ + + x <- faithful$waiting + bins <- seq(min(x), max(x), length.out = input$bins + 1) + + hist(x, breaks = bins, col = "#75AADB", border = "white", + xlab = "Waiting time to next eruption (in mins)", + main = "Histogram of waiting times") + + }) + +} + +# Create Shiny app ---- +shinyApp(ui = ui, server = server) diff --git a/examples/r/002-text/DESCRIPTION b/examples/r/002-text/DESCRIPTION new file mode 100644 index 00000000..8f62a976 --- /dev/null +++ b/examples/r/002-text/DESCRIPTION @@ -0,0 +1,8 @@ +Title: Shiny Text +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny + diff --git a/examples/r/002-text/Readme.md b/examples/r/002-text/Readme.md new file mode 100644 index 00000000..7aa04e5e --- /dev/null +++ b/examples/r/002-text/Readme.md @@ -0,0 +1 @@ +This example demonstrates output of raw text from R using the `renderPrint` function in `server` and the `verbatimTextOutput` function in `ui`. In this case, a textual summary of the data is shown using R's built-in `summary` function. diff --git a/examples/r/002-text/about.txt b/examples/r/002-text/about.txt new file mode 100644 index 00000000..91f75773 --- /dev/null +++ b/examples/r/002-text/about.txt @@ -0,0 +1 @@ +Shiny Text diff --git a/examples/r/002-text/app.R b/examples/r/002-text/app.R new file mode 100644 index 00000000..9aff1e85 --- /dev/null +++ b/examples/r/002-text/app.R @@ -0,0 +1,64 @@ +library(shiny) + +# Define UI for dataset viewer app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Shiny Text"), + + # Sidebar layout with a input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Selector for choosing dataset ---- + selectInput(inputId = "dataset", + label = "Choose a dataset:", + choices = c("rock", "pressure", "cars")), + + # Input: Numeric entry for number of obs to view ---- + numericInput(inputId = "obs", + label = "Number of observations to view:", + value = 10) + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Verbatim text for data summary ---- + verbatimTextOutput("summary"), + + # Output: HTML table with requested number of observations ---- + tableOutput("view") + + ) + ) +) + +# Define server logic to summarize and view selected dataset ---- +server <- function(input, output) { + + # Return the requested dataset ---- + datasetInput <- reactive({ + switch(input$dataset, + "rock" = rock, + "pressure" = pressure, + "cars" = cars) + }) + + # Generate a summary of the dataset ---- + output$summary <- renderPrint({ + dataset <- datasetInput() + summary(dataset) + }) + + # Show the first "n" observations ---- + output$view <- renderTable({ + head(datasetInput(), n = input$obs) + }) + +} + +# Create Shiny app ---- +shinyApp(ui = ui, server = server) diff --git a/examples/r/003-reactivity/DESCRIPTION b/examples/r/003-reactivity/DESCRIPTION new file mode 100644 index 00000000..01db04d4 --- /dev/null +++ b/examples/r/003-reactivity/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Reactivity +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/003-reactivity/Readme.md b/examples/r/003-reactivity/Readme.md new file mode 100644 index 00000000..8eaee27e --- /dev/null +++ b/examples/r/003-reactivity/Readme.md @@ -0,0 +1,5 @@ +This example demonstrates a core feature of Shiny: **reactivity**. In the `server` function, a reactive called `datasetInput` is declared. + +Notice that the reactive expression depends on the input expression `input$dataset`, and that it's used by two output expressions: `output$summary` and `output$view`. Try changing the dataset (using *Choose a dataset*) while looking at the reactive and then at the outputs; you will see first the reactive and then its dependencies flash. + +Notice also that the reactive expression doesn't just update whenever anything changes--only the inputs it depends on will trigger an update. Change the "Caption" field and notice how only the `output$caption` expression is re-evaluated; the reactive and its dependents are left alone. diff --git a/examples/r/003-reactivity/about.txt b/examples/r/003-reactivity/about.txt new file mode 100644 index 00000000..bb2c35b6 --- /dev/null +++ b/examples/r/003-reactivity/about.txt @@ -0,0 +1 @@ +Reactivity diff --git a/examples/r/003-reactivity/app.R b/examples/r/003-reactivity/app.R new file mode 100644 index 00000000..393ebfe6 --- /dev/null +++ b/examples/r/003-reactivity/app.R @@ -0,0 +1,102 @@ +library(shiny) + +# Define UI for dataset viewer app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Reactivity"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Text for providing a caption ---- + # Note: Changes made to the caption in the textInput control + # are updated in the output area immediately as you type + textInput(inputId = "caption", + label = "Caption:", + value = "Data Summary"), + + # Input: Selector for choosing dataset ---- + selectInput(inputId = "dataset", + label = "Choose a dataset:", + choices = c("rock", "pressure", "cars")), + + # Input: Numeric entry for number of obs to view ---- + numericInput(inputId = "obs", + label = "Number of observations to view:", + value = 10) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Formatted text for caption ---- + h3(textOutput("caption", container = span)), + + # Output: Verbatim text for data summary ---- + verbatimTextOutput("summary"), + + # Output: HTML table with requested number of observations ---- + tableOutput("view") + + ) + ) +) + +# Define server logic to summarize and view selected dataset ---- +server <- function(input, output) { + + # Return the requested dataset ---- + # By declaring datasetInput as a reactive expression we ensure + # that: + # + # 1. It is only called when the inputs it depends on changes + # 2. The computation and result are shared by all the callers, + # i.e. it only executes a single time + datasetInput <- reactive({ + switch(input$dataset, + "rock" = rock, + "pressure" = pressure, + "cars" = cars) + }) + + # Create caption ---- + # The output$caption is computed based on a reactive expression + # that returns input$caption. When the user changes the + # "caption" field: + # + # 1. This function is automatically called to recompute the output + # 2. New caption is pushed back to the browser for re-display + # + # Note that because the data-oriented reactive expressions + # below don't depend on input$caption, those expressions are + # NOT called when input$caption changes + output$caption <- renderText({ + input$caption + }) + + # Generate a summary of the dataset ---- + # The output$summary depends on the datasetInput reactive + # expression, so will be re-executed whenever datasetInput is + # invalidated, i.e. whenever the input$dataset changes + output$summary <- renderPrint({ + dataset <- datasetInput() + summary(dataset) + }) + + # Show the first "n" observations ---- + # The output$view depends on both the databaseInput reactive + # expression and input$obs, so it will be re-executed whenever + # input$dataset or input$obs is changed + output$view <- renderTable({ + head(datasetInput(), n = input$obs) + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/004-mpg/DESCRIPTION b/examples/r/004-mpg/DESCRIPTION new file mode 100644 index 00000000..324145bb --- /dev/null +++ b/examples/r/004-mpg/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Miles Per Gallon +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/004-mpg/Readme.md b/examples/r/004-mpg/Readme.md new file mode 100644 index 00000000..33c0fe09 --- /dev/null +++ b/examples/r/004-mpg/Readme.md @@ -0,0 +1,4 @@ +This example demonstrates the following concepts: + +- **Global variables**: The `mpgData` variable is declared outside of the `ui` and `server` function definitions. This makes it available anywhere inside `app.R`. The code in `app.R` outside of `ui` and `server` function definitions is only run once when the app starts up, so it can't contain user input. +- **Reactive expressions**: `formulaText` is a reactive expression. Note how it re-evaluates when the Variable field is changed, but not when the Show Outliers box is unchecked. diff --git a/examples/r/004-mpg/about.txt b/examples/r/004-mpg/about.txt new file mode 100644 index 00000000..ea336380 --- /dev/null +++ b/examples/r/004-mpg/about.txt @@ -0,0 +1 @@ +Miles Per Gallon diff --git a/examples/r/004-mpg/app.R b/examples/r/004-mpg/app.R new file mode 100644 index 00000000..e789bedc --- /dev/null +++ b/examples/r/004-mpg/app.R @@ -0,0 +1,75 @@ +library(shiny) +library(datasets) + +# Data pre-processing ---- +# Tweak the "am" variable to have nicer factor labels -- since this +# doesn't rely on any user inputs, we can do this once at startup +# and then use the value throughout the lifetime of the app +mpgData <- mtcars +mpgData$am <- factor(mpgData$am, labels = c("Automatic", "Manual")) + + +# Define UI for miles per gallon app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Miles Per Gallon"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Selector for variable to plot against mpg ---- + selectInput("variable", "Variable:", + c("Cylinders" = "cyl", + "Transmission" = "am", + "Gears" = "gear")), + + # Input: Checkbox for whether outliers should be included ---- + checkboxInput("outliers", "Show outliers", TRUE) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Formatted text for caption ---- + h3(textOutput("caption")), + + # Output: Plot of the requested variable against mpg ---- + plotOutput("mpgPlot") + + ) + ) +) + +# Define server logic to plot various variables against mpg ---- +server <- function(input, output) { + + # Compute the formula text ---- + # This is in a reactive expression since it is shared by the + # output$caption and output$mpgPlot functions + formulaText <- reactive({ + paste("mpg ~", input$variable) + }) + + # Return the formula text for printing as a caption ---- + output$caption <- renderText({ + formulaText() + }) + + # Generate a plot of the requested variable against mpg ---- + # and only exclude outliers if requested + output$mpgPlot <- renderPlot({ + boxplot(as.formula(formulaText()), + data = mpgData, + outline = input$outliers, + col = "#75AADB", pch = 19) + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/005-sliders/DESCRIPTION b/examples/r/005-sliders/DESCRIPTION new file mode 100644 index 00000000..954fddbf --- /dev/null +++ b/examples/r/005-sliders/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Sliders +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/005-sliders/Readme.md b/examples/r/005-sliders/Readme.md new file mode 100644 index 00000000..1b0cf572 --- /dev/null +++ b/examples/r/005-sliders/Readme.md @@ -0,0 +1,3 @@ +This example demonstrates Shiny's versatile `sliderInput` widget. + +Slider inputs can be used to select single values, to select a continuous range of values, and even to animate over a range. diff --git a/examples/r/005-sliders/about.txt b/examples/r/005-sliders/about.txt new file mode 100644 index 00000000..37fd3711 --- /dev/null +++ b/examples/r/005-sliders/about.txt @@ -0,0 +1 @@ +Sliders diff --git a/examples/r/005-sliders/app.R b/examples/r/005-sliders/app.R new file mode 100644 index 00000000..d9aa05c7 --- /dev/null +++ b/examples/r/005-sliders/app.R @@ -0,0 +1,86 @@ +library(shiny) + +# Define UI for slider demo app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Sliders"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar to demonstrate various slider options ---- + sidebarPanel( + + # Input: Simple integer interval ---- + sliderInput("integer", "Integer:", + min = 0, max = 1000, + value = 500), + + # Input: Decimal interval with step value ---- + sliderInput("decimal", "Decimal:", + min = 0, max = 1, + value = 0.5, step = 0.1), + + # Input: Specification of range within an interval ---- + sliderInput("range", "Range:", + min = 1, max = 1000, + value = c(200,500)), + + # Input: Custom currency format for with basic animation ---- + sliderInput("format", "Custom Format:", + min = 0, max = 10000, + value = 0, step = 2500, + pre = "$", sep = ",", + animate = TRUE), + + # Input: Animation with custom interval (in ms) ---- + # to control speed, plus looping + sliderInput("animation", "Looping Animation:", + min = 1, max = 2000, + value = 1, step = 10, + animate = + animationOptions(interval = 300, loop = TRUE)) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Table summarizing the values entered ---- + tableOutput("values") + + ) + ) +) + +# Define server logic for slider examples ---- +server <- function(input, output) { + + # Reactive expression to create data frame of all input values ---- + sliderValues <- reactive({ + + data.frame( + Name = c("Integer", + "Decimal", + "Range", + "Custom Format", + "Animation"), + Value = as.character(c(input$integer, + input$decimal, + paste(input$range, collapse = " "), + input$format, + input$animation)), + stringsAsFactors = FALSE) + + }) + + # Show the values in an HTML table ---- + output$values <- renderTable({ + sliderValues() + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/006-tabsets/DESCRIPTION b/examples/r/006-tabsets/DESCRIPTION new file mode 100644 index 00000000..52c09c57 --- /dev/null +++ b/examples/r/006-tabsets/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Tabsets +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/006-tabsets/Readme.md b/examples/r/006-tabsets/Readme.md new file mode 100644 index 00000000..c548d306 --- /dev/null +++ b/examples/r/006-tabsets/Readme.md @@ -0,0 +1,9 @@ +This example demonstrates the `tabsetPanel` and `tabPanel` widgets. + +Notice that outputs that are not visible are not re-evaluated until they become visible. Try this: + +1. Scroll to the bottom of the `server` function. You might need to use the *show with app* option so you can easily view the code and interact with the app at the same time. +2. Change the number of observations, and observe that only `output$plot` is evaluated. +3. Click the Summary tab, and observe that `output$summary` is evaluated. +4. Change the number of observations again, and observe that now only `output$summary` is evaluated. + diff --git a/examples/r/006-tabsets/about.txt b/examples/r/006-tabsets/about.txt new file mode 100644 index 00000000..68662241 --- /dev/null +++ b/examples/r/006-tabsets/about.txt @@ -0,0 +1 @@ +Tabsets diff --git a/examples/r/006-tabsets/app.R b/examples/r/006-tabsets/app.R new file mode 100644 index 00000000..e1b89d66 --- /dev/null +++ b/examples/r/006-tabsets/app.R @@ -0,0 +1,92 @@ +library(shiny) + +# Define UI for random distribution app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Tabsets"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Select the random distribution type ---- + radioButtons("dist", "Distribution type:", + c("Normal" = "norm", + "Uniform" = "unif", + "Log-normal" = "lnorm", + "Exponential" = "exp")), + + # br() element to introduce extra vertical spacing ---- + br(), + + # Input: Slider for the number of observations to generate ---- + sliderInput("n", + "Number of observations:", + value = 500, + min = 1, + max = 1000) + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Tabset w/ plot, summary, and table ---- + tabsetPanel(type = "tabs", + tabPanel("Plot", plotOutput("plot")), + tabPanel("Summary", verbatimTextOutput("summary")), + tabPanel("Table", tableOutput("table")) + ) + + ) + ) +) + +# Define server logic for random distribution app ---- +server <- function(input, output) { + + # Reactive expression to generate the requested distribution ---- + # This is called whenever the inputs change. The output functions + # defined below then use the value computed from this expression + d <- reactive({ + dist <- switch(input$dist, + norm = rnorm, + unif = runif, + lnorm = rlnorm, + exp = rexp, + rnorm) + + dist(input$n) + }) + + # Generate a plot of the data ---- + # Also uses the inputs to build the plot label. Note that the + # dependencies on the inputs and the data reactive expression are + # both tracked, and all expressions are called in the sequence + # implied by the dependency graph. + output$plot <- renderPlot({ + dist <- input$dist + n <- input$n + + hist(d(), + main = paste("r", dist, "(", n, ")", sep = ""), + col = "#75AADB", border = "white") + }) + + # Generate a summary of the data ---- + output$summary <- renderPrint({ + summary(d()) + }) + + # Generate an HTML table view of the data ---- + output$table <- renderTable({ + d() + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/007-widgets/DESCRIPTION b/examples/r/007-widgets/DESCRIPTION new file mode 100644 index 00000000..b2ad51f7 --- /dev/null +++ b/examples/r/007-widgets/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Widgets +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/007-widgets/Readme.md b/examples/r/007-widgets/Readme.md new file mode 100644 index 00000000..25a4ad02 --- /dev/null +++ b/examples/r/007-widgets/Readme.md @@ -0,0 +1 @@ +This example demonstrates some additional widgets included in Shiny, such as `helpText` and `actionButton`. The latter is used to delay rendering output until the user explicitly requests it (a construct which also introduces two important server functions, `eventReactive` and `isolate`). diff --git a/examples/r/007-widgets/about.txt b/examples/r/007-widgets/about.txt new file mode 100644 index 00000000..4e9b5656 --- /dev/null +++ b/examples/r/007-widgets/about.txt @@ -0,0 +1 @@ +Widgets diff --git a/examples/r/007-widgets/app.R b/examples/r/007-widgets/app.R new file mode 100644 index 00000000..4ad8e88e --- /dev/null +++ b/examples/r/007-widgets/app.R @@ -0,0 +1,82 @@ +library(shiny) + +# Define UI for dataset viewer app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("More Widgets"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Select a dataset ---- + selectInput("dataset", "Choose a dataset:", + choices = c("rock", "pressure", "cars")), + + # Input: Specify the number of observations to view ---- + numericInput("obs", "Number of observations to view:", 10), + + # Include clarifying text ---- + helpText("Note: while the data view will show only the specified", + "number of observations, the summary will still be based", + "on the full dataset."), + + # Input: actionButton() to defer the rendering of output ---- + # until the user explicitly clicks the button (rather than + # doing it immediately when inputs change). This is useful if + # the computations required to render output are inordinately + # time-consuming. + actionButton("update", "Update View") + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Header + summary of distribution ---- + h4("Summary"), + verbatimTextOutput("summary"), + + # Output: Header + table of distribution ---- + h4("Observations"), + tableOutput("view") + ) + + ) +) + +# Define server logic to summarize and view selected dataset ---- +server <- function(input, output) { + + # Return the requested dataset ---- + # Note that we use eventReactive() here, which depends on + # input$update (the action button), so that the output is only + # updated when the user clicks the button + datasetInput <- eventReactive(input$update, { + switch(input$dataset, + "rock" = rock, + "pressure" = pressure, + "cars" = cars) + }, ignoreNULL = FALSE) + + # Generate a summary of the dataset ---- + output$summary <- renderPrint({ + dataset <- datasetInput() + summary(dataset) + }) + + # Show the first "n" observations ---- + # The use of isolate() is necessary because we don't want the table + # to update whenever input$obs changes (only when the user clicks + # the action button) + output$view <- renderTable({ + head(datasetInput(), n = isolate(input$obs)) + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/008-html/DESCRIPTION b/examples/r/008-html/DESCRIPTION new file mode 100644 index 00000000..76a9c218 --- /dev/null +++ b/examples/r/008-html/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Custom HTML UI +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/008-html/Readme.md b/examples/r/008-html/Readme.md new file mode 100644 index 00000000..c2c898dc --- /dev/null +++ b/examples/r/008-html/Readme.md @@ -0,0 +1 @@ +Normally we use the built-in functions, such as `textInput()`, to generate the HTML UI in the R script `ui.R`. Actually **shiny** also works with a custom HTML page `www/index.html`. See [the tutorial](http://shiny.rstudio.com/tutorial/) for more details. diff --git a/examples/r/008-html/about.txt b/examples/r/008-html/about.txt new file mode 100644 index 00000000..6411566d --- /dev/null +++ b/examples/r/008-html/about.txt @@ -0,0 +1 @@ +Custom HTML UI diff --git a/examples/r/008-html/app.R b/examples/r/008-html/app.R new file mode 100644 index 00000000..be8d3784 --- /dev/null +++ b/examples/r/008-html/app.R @@ -0,0 +1,47 @@ +library(shiny) + +# Define server logic for random distribution app ---- +server <- function(input, output) { + + # Reactive expression to generate the requested distribution ---- + # This is called whenever the inputs change. The output functions + # defined below then use the value computed from this expression + d <- reactive({ + dist <- switch(input$dist, + norm = rnorm, + unif = runif, + lnorm = rlnorm, + exp = rexp, + rnorm) + + dist(input$n) + }) + + # Generate a plot of the data ---- + # Also uses the inputs to build the plot label. Note that the + # dependencies on the inputs and the data reactive expression are + # both tracked, and all expressions are called in the sequence + # implied by the dependency graph. + output$plot <- renderPlot({ + dist <- input$dist + n <- input$n + + hist(d(), + main = paste("r", dist, "(", n, ")", sep = ""), + col = "#75AADB", border = "white") + }) + + # Generate a summary of the data ---- + output$summary <- renderPrint({ + summary(d()) + }) + + # Generate an HTML table view of the head of the data ---- + output$table <- renderTable({ + head(data.frame(x = d())) + }) + +} + +# Create Shiny app ---- +shinyApp(ui = htmlTemplate("www/index.html"), server) diff --git a/examples/r/008-html/www/index.html b/examples/r/008-html/www/index.html new file mode 100644 index 00000000..b32adc30 --- /dev/null +++ b/examples/r/008-html/www/index.html @@ -0,0 +1,41 @@ + + + + + + + + + + +

HTML UI

+ +

+
+ +

+ +

+ +
+ + +

+ +

Summary of data:

+

+
+  

Plot of data:

+
+ +

Head of data:

+
+ + + diff --git a/examples/r/009-upload/DESCRIPTION b/examples/r/009-upload/DESCRIPTION new file mode 100644 index 00000000..b5e24c3a --- /dev/null +++ b/examples/r/009-upload/DESCRIPTION @@ -0,0 +1,7 @@ +Title: File Upload +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/009-upload/Readme.md b/examples/r/009-upload/Readme.md new file mode 100644 index 00000000..3c45f363 --- /dev/null +++ b/examples/r/009-upload/Readme.md @@ -0,0 +1,3 @@ +We can add a file upload input in the UI using the function `fileInput()`, +e.g. `fileInput('foo')`. In the `server` function, we can access the +uploaded files via `input$foo`. diff --git a/examples/r/009-upload/about.txt b/examples/r/009-upload/about.txt new file mode 100644 index 00000000..b20636f5 --- /dev/null +++ b/examples/r/009-upload/about.txt @@ -0,0 +1 @@ +R File Upload diff --git a/examples/r/009-upload/app.R b/examples/r/009-upload/app.R new file mode 100644 index 00000000..19eb29f8 --- /dev/null +++ b/examples/r/009-upload/app.R @@ -0,0 +1,102 @@ +library(shiny) + +# Define UI for data upload app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Uploading Files"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Select a file ---- + fileInput("file1", "Choose CSV File", + multiple = FALSE, + accept = c("text/csv", + "text/comma-separated-values,text/plain", + ".csv")), + + # Horizontal line ---- + tags$hr(), + + # Input: Checkbox if file has header ---- + checkboxInput("header", "Header", TRUE), + + # Input: Select separator ---- + radioButtons("sep", "Separator", + choices = c(Comma = ",", + Semicolon = ";", + Tab = "\t"), + selected = ","), + + # Input: Select quotes ---- + radioButtons("quote", "Quote", + choices = c(None = "", + "Double Quote" = '\\"', + "Single Quote" = "'"), + selected = '\\"'), + + # Horizontal line ---- + tags$hr(), + + # Input: Select number of rows to display ---- + radioButtons("disp", "Display", + choices = c(Head = "head", + All = "all"), + selected = "head") + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + # Output: Data file ---- + tableOutput("contents") + + ) + + ) +) + +# Define server logic to read selected file ---- +server <- function(input, output) { + + output$contents <- renderTable({ + + # input$file1 will be NULL initially. After the user selects + # and uploads a file, head of that data file by default, + # or all rows if selected, will be shown. + + req(input$file1) + + # when reading semicolon separated files, + # having a comma separator causes `read.csv` to error + tryCatch( + { + df <- read.csv(input$file1$datapath, + header = input$header, + sep = input$sep, + quote = input$quote) + }, + error = function(e) { + # return a safeError if a parsing error occurs + stop(safeError(e)) + } + ) + + if(input$disp == "head") { + return(head(df)) + } + else { + return(df) + } + + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/010-download/DESCRIPTION b/examples/r/010-download/DESCRIPTION new file mode 100644 index 00000000..3eb34a7f --- /dev/null +++ b/examples/r/010-download/DESCRIPTION @@ -0,0 +1,7 @@ +Title: File Download +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/010-download/Readme.md b/examples/r/010-download/Readme.md new file mode 100644 index 00000000..fceb331a --- /dev/null +++ b/examples/r/010-download/Readme.md @@ -0,0 +1,2 @@ +We can add a download button to the UI using `downloadButton()`, and write +the content of the file in `downloadHandler()` in the `server` function. diff --git a/examples/r/010-download/about.txt b/examples/r/010-download/about.txt new file mode 100644 index 00000000..67f3ee20 --- /dev/null +++ b/examples/r/010-download/about.txt @@ -0,0 +1 @@ +R File Download diff --git a/examples/r/010-download/app.R b/examples/r/010-download/app.R new file mode 100644 index 00000000..745ffc36 --- /dev/null +++ b/examples/r/010-download/app.R @@ -0,0 +1,70 @@ +library(shiny) + +# Workaround for Chromium Issue 468227 +downloadButton <- function(...) { + tag <- shiny::downloadButton("downloadData", "Download") + tag$attribs$download <- NULL + tag +} + +# Define UI for data download app ---- +ui <- fluidPage( + + # App title ---- + titlePanel("Downloading Data"), + + # Sidebar layout with input and output definitions ---- + sidebarLayout( + + # Sidebar panel for inputs ---- + sidebarPanel( + + # Input: Choose dataset ---- + selectInput("dataset", "Choose a dataset:", + choices = c("rock", "pressure", "cars")), + + # Button + downloadButton("downloadData", "Download") + + ), + + # Main panel for displaying outputs ---- + mainPanel( + + tableOutput("table") + + ) + + ) +) + +# Define server logic to display and download selected file ---- +server <- function(input, output) { + + # Reactive value for selected dataset ---- + datasetInput <- reactive({ + switch(input$dataset, + "rock" = rock, + "pressure" = pressure, + "cars" = cars) + }) + + # Table of selected dataset ---- + output$table <- renderTable({ + datasetInput() + }) + + # Downloadable csv of selected dataset ---- + output$downloadData <- downloadHandler( + filename = function() { + paste(input$dataset, ".csv", sep = "") + }, + content = function(file) { + write.csv(datasetInput(), file, row.names = FALSE) + } + ) + +} + +# Create Shiny app ---- +shinyApp(ui, server) diff --git a/examples/r/011-timer/DESCRIPTION b/examples/r/011-timer/DESCRIPTION new file mode 100644 index 00000000..3412d2f9 --- /dev/null +++ b/examples/r/011-timer/DESCRIPTION @@ -0,0 +1,7 @@ +Title: Timer +Author: RStudio, Inc. +AuthorUrl: http://www.rstudio.com/ +License: MIT +DisplayMode: Showcase +Tags: getting-started +Type: Shiny diff --git a/examples/r/011-timer/Readme.md b/examples/r/011-timer/Readme.md new file mode 100644 index 00000000..b9a61738 --- /dev/null +++ b/examples/r/011-timer/Readme.md @@ -0,0 +1,4 @@ +The function `invalidateLater()` can be used to invalidate an observer or +reactive expression in a given number of milliseconds. In this example, the +output `currentTime` is updated every second, so it shows the current time +on a second basis. diff --git a/examples/r/011-timer/about.txt b/examples/r/011-timer/about.txt new file mode 100644 index 00000000..768aab3a --- /dev/null +++ b/examples/r/011-timer/about.txt @@ -0,0 +1 @@ +Timer diff --git a/examples/r/011-timer/app.R b/examples/r/011-timer/app.R new file mode 100644 index 00000000..6aaf3792 --- /dev/null +++ b/examples/r/011-timer/app.R @@ -0,0 +1,21 @@ +library(shiny) + +# Define UI for displaying current time ---- +ui <- fluidPage( + + h2(textOutput("currentTime")) + +) + +# Define server logic to show current time, update every second ---- +server <- function(input, output, session) { + + output$currentTime <- renderText({ + invalidateLater(1000, session) + paste("The current time is", Sys.time()) + }) + +} + +# Create Shiny app ---- +shinyApp(ui, server) From e739e5358496c8019bc38ee9104c6692586f8c92 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Wed, 26 Jul 2023 10:48:47 +0100 Subject: [PATCH 12/21] Show R and Python examples based on Wasm engine --- scripts/build_examples_json.ts | 13 +++++++------ src/Components/App.tsx | 5 ++++- src/Components/ExampleSelector.tsx | 8 ++++++-- src/examples.ts | 4 ++++ 4 files changed, 21 insertions(+), 9 deletions(-) diff --git a/scripts/build_examples_json.ts b/scripts/build_examples_json.ts index 251270ba..e30837a7 100644 --- a/scripts/build_examples_json.ts +++ b/scripts/build_examples_json.ts @@ -25,8 +25,8 @@ export default function buildExamples(examplesDir: string, buildDir: string) { fs.readFileSync(orderingFile).toString() ); - function parseApp(exampleDir: string) { - const appPath = `${examplesDir}/${exampleDir}`; + function parseApp(exampleDir: string, engine: string) { + const appPath = `${examplesDir}/${engine}/${exampleDir}`; if (!fs.existsSync(appPath)) { throw new Error( @@ -61,8 +61,8 @@ export default function buildExamples(examplesDir: string, buildDir: string) { .sort((a: string, b: string) => { // Sort files, with "app.py" first, and other files in normal sorted // order. - if (a === "app.py") return -1; - if (b === "app.py") return 1; + if (a === "app.py" || a === "app.R" || a === "server.R") return -1; + if (b === "app.py" || b === "app.R" || b === "server.R") return 1; if (a < b) return -1; if (a > b) return 1; @@ -91,9 +91,10 @@ export default function buildExamples(examplesDir: string, buildDir: string) { fs.writeFileSync( outputFile, JSON.stringify( - ordering.map(({ category, apps }) => ({ + ordering.map(({ category, engine, apps }) => ({ category, - apps: apps.map(parseApp), + engine, + apps: apps.map((app) => parseApp(app, engine)), })), null, 2 diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 39f3e7b9..1e502f73 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -334,6 +334,7 @@ export function App({ setCurrentFiles={setCurrentFiles} filesHaveChanged={filesHaveChanged} startWithSelectedExample={appOptions.selectedExample} + appEngine={appEngine} /> Loading...
}> cat.engine === appEngine + ); let pos = findExampleByTitle(exampleName, exampleCategories); if (pos) { opts.selectedExample = exampleName; diff --git a/src/Components/ExampleSelector.tsx b/src/Components/ExampleSelector.tsx index 879dc080..05a0cb96 100644 --- a/src/Components/ExampleSelector.tsx +++ b/src/Components/ExampleSelector.tsx @@ -6,6 +6,7 @@ import { getExampleCategories, sanitizeTitleForUrl, } from "../examples"; +import { AppEngine } from "./App"; import "./ExampleSelector.css"; import { FileContent } from "./filecontent"; import * as React from "react"; @@ -14,10 +15,12 @@ export function ExampleSelector({ setCurrentFiles, filesHaveChanged, startWithSelectedExample, + appEngine, }: { setCurrentFiles: React.Dispatch>; filesHaveChanged: boolean; startWithSelectedExample?: string; + appEngine: AppEngine; }) { const [currentSelection, setCurrentSelection] = React.useState(null); @@ -29,9 +32,10 @@ export function ExampleSelector({ React.useEffect(() => { (async () => { - setExampleCategories(await getExampleCategories()); + const categories = await getExampleCategories(); + setExampleCategories(categories.filter((cat) => cat.engine === appEngine)); })(); - }, []); + }, [appEngine]); // If we were told to start with a specific example, find it and select it. React.useEffect(() => { diff --git a/src/examples.ts b/src/examples.ts index 790ac50e..b2112673 100644 --- a/src/examples.ts +++ b/src/examples.ts @@ -13,12 +13,14 @@ export type ExampleItemJson = { // For examples/index.json export type ExampleCategoryIndexJson = { category: string; + engine: string; apps: string[]; }; // For examples.json export type ExampleCategoryJson = { category: string; + engine: string; apps: ExampleItemJson[]; }; @@ -30,6 +32,7 @@ export type ExampleItem = { export type ExampleCategory = { category: string; + engine: string; apps: ExampleItem[]; }; @@ -93,6 +96,7 @@ function exampleCategoryJsonToExampleCategory( ): ExampleCategory { return { category: x.category, + engine: x.engine, apps: x.apps.map(exampleItemJsonToExampleItem), }; } From a318ef03d39568a0d45e5327f88041b47563730f Mon Sep 17 00:00:00 2001 From: George Stagg Date: Wed, 26 Jul 2023 10:50:46 +0100 Subject: [PATCH 13/21] Enable wasm engine to be selected at build time Three Makefile targets are added to build and serve with webR as the default engine: * make serve-r * make serve-prod-r * make buildjs-prod-r --- Makefile | 12 ++++++++++++ scripts/build.ts | 6 ++++++ src/Components/App.tsx | 4 ++-- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 08bec34d..9715d980 100644 --- a/Makefile +++ b/Makefile @@ -212,6 +212,18 @@ serve: serve-prod: node_modules/.bin/tsx scripts/build.ts --serve --prod +# Build JS resources (with minification) with webR as the default engine +buildjs-prod-r: + node_modules/.bin/tsx scripts/build.ts --prod --r + +# Build and serve (with minification) with webR as the default engine +serve-prod-r: + node_modules/.bin/tsx scripts/build.ts --serve --prod --r + +# Build and serve with webR as the default engine +serve-r: + node_modules/.bin/tsx scripts/build.ts --serve --r + # Build htmltools, shiny, and shinywidgets. This target must be run manually after # updating the package submodules; it will not run automatically with `make all` diff --git a/scripts/build.ts b/scripts/build.ts index 3503ffc9..9bf83379 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -5,6 +5,7 @@ import http from "http"; import process from "process"; import packageJson from "../package.json"; import buildExamples from "./build_examples_json"; +import type { AppEngine } from '../src/Components/App'; const EXAMPLES_SOURCE_DIR = "./examples"; const BUILD_DIR = "./build"; @@ -24,6 +25,7 @@ let serve = false; let openBrowser = true; let minify = false; let reactProductionMode = false; +let appEngine: AppEngine = "python"; // Set this to true to generate a metadata file that can be analyzed for size of // modules in the bundle, like with Bundle-Buddy. const metafile = process.argv.includes("--metafile"); @@ -44,6 +46,9 @@ if (process.argv.includes("--test-server")) { watch = false; openBrowser = false; } +if (process.argv.includes("--r")) { + appEngine = "r"; +} function createRebuildLoggerPlugin(label: string) { return { @@ -106,6 +111,7 @@ const buildmap = { banner: banner, metafile: metafile, define: { + "process.env.APP_ENGINE": `"${appEngine}"`, "process.env.NODE_ENV": reactProductionMode ? '"production"' : '"development"', diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 1e502f73..c7e4df66 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -172,7 +172,7 @@ export function App({ appMode = "examples-editor-terminal-viewer", startFiles = [], appOptions = {}, - appEngine = "python" + appEngine = process.env.APP_ENGINE as AppEngine, }: { appMode: AppMode; startFiles: FileContent[]; @@ -528,7 +528,7 @@ export function runApp( allowGistUrl?: boolean; allowExampleUrl?: boolean; } = {}, - appEngine: AppEngine = "python", + appEngine: AppEngine = process.env.APP_ENGINE as AppEngine, ) { const optsDefaults = { allowCodeUrl: false, From 1194ed670a1b8c7404119de597ec3522c90f6749 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 21 Aug 2023 13:46:09 +0100 Subject: [PATCH 14/21] Create httpuv object and invoke callbacks directly --- src/Components/Viewer.tsx | 33 +++++++-- src/hooks/useWebR.tsx | 111 ++++++++++++++++++++-------- src/messageporthttp.ts | 53 +++++++------ src/messageportwebsocket-channel.ts | 65 ++++++++++------ src/webr-proxy.ts | 16 ++-- 5 files changed, 189 insertions(+), 89 deletions(-) diff --git a/src/Components/Viewer.tsx b/src/Components/Viewer.tsx index 120b44e7..287ffbe9 100644 --- a/src/Components/Viewer.tsx +++ b/src/Components/Viewer.tsx @@ -113,7 +113,7 @@ async function resetRAppFrame( // see the flash of gray indicating a closed session. appFrame.src = ""; - await webRProxy.runRAsync('.stop_app()'); + await webRProxy.runRAsync(`.stop_app('${appName}')`); // Pause for a bit before continuing. await utils.sleep(200); @@ -133,7 +133,7 @@ export function Viewer({ const [appRunningState, setAppRunningState] = React.useState< "loading" | "running" | "errored" | "empty" >("loading"); - + const shinyIntervalRef = React.useRef(0); const [lastErrorMessage, setLastErrorMessage] = React.useState( null ); @@ -167,15 +167,33 @@ export function Viewer({ const appDir = "/home/web_user/" + appName; const shelter = await new webRProxy.webR.Shelter(); const files = await new shelter.RList( - Object.fromEntries(appCode.map((file) => { return [file.name, file.content] })) + Object.fromEntries(appCode.map((file) => { + return [file.name, file.content] + })) ) try { - await webRProxy.runRAsync('.save_files(files, appDir)', { env: { files, appDir } }); - // This blocks in Shiny's runApp() - webRProxy.runCode(`.start_app("${appDir}")`); + await webRProxy.runRAsync( + '.save_files(files, appDir)', + { env: { files, appDir }, captureStreams: false } + ); + await webRProxy.runRAsync( + '.start_app(appName, appDir)', + { + env: { appName, appDir }, + captureConditions: false, + captureStreams: false + } + ); } finally { shelter.purge(); } + + // Run R Shiny housekeeping every 100ms + if (shinyIntervalRef.current) clearTimeout(shinyIntervalRef.current); + shinyIntervalRef.current = window.setInterval(() => { + webRProxy.runRAsync('.shiny_tick()') + }, 100); + viewerFrameRef.current.src = appInfo.urlPath; setAppRunningState("running"); } catch (e) { @@ -192,6 +210,9 @@ export function Viewer({ async function stopApp(): Promise { if (!viewerFrameRef.current) return; + // Stop the periodic R Shiny housekeeping + if (shinyIntervalRef.current) clearTimeout(shinyIntervalRef.current); + await resetRAppFrame( webRProxy, appInfo.appName, diff --git a/src/hooks/useWebR.tsx b/src/hooks/useWebR.tsx index e96ccb18..b653a853 100644 --- a/src/hooks/useWebR.tsx +++ b/src/hooks/useWebR.tsx @@ -128,27 +128,81 @@ function ensureOpenChannelListener(webRProxy: WebRProxy): void { const load_r_pre = ` -.RawReader <- setRefClass("RawReader", fields = c("con", "length"), methods = list( - init = function(bytes) { - con <<- rawConnection(bytes, "rb") - length <<- length(bytes) - }, - read = function(l = -1L) { - if (l < 0) l <- length - readBin(con, "raw", size = 1, n = l) - }, - read_lines = function(l = -1L) { - readLines(con, n = l) - }, - rewind = function() { - seek(con, 0) - }, - destroy = function() { - close(con) - } -)) +.shiny_app_registry <- new.env() + +# Create a httpuv app from a Shiny app directory +.shiny_to_httpuv <- function(appDir) { + # Create an appObj from an app directory + appObj <- shiny::as.shiny.appobj(appDir) + + # Ensure global.R is sourced when app starts + appObj$onStart() + + # Required so that downloadLink and registerDataObj work + shiny:::workerId("") + + # Creates http and ws handlers from the app object. However, these are not + # Rook handlers, but rather use Shiny's own middleware protocol. + # https://github.com/rstudio/shiny/blob/main/R/middleware.R + appHandlers <- shiny:::createAppHandlers( + appObj$httpHandler, + appObj$serverFuncSource + ) + + # HandlerManager turns Shiny middleware into httpuv apps + handlerManager <- shiny:::HandlerManager$new() + handlerManager$addHandler(appHandlers$http, "/", tail = TRUE) + handlerManager$addWSHandler(appHandlers$ws, "/", tail = TRUE) + handlerManager$createHttpuvApp() +} -.save_files <- function (files, appDir) { +# Run Shiny housekeeping tasks +# https://github.com/rstudio/shiny/blob/b054e45402ee31f1e58cb6e1d1f51f76f98a0aca/R/server.R#L479 +.shiny_tick <- function() { + shiny:::timerCallbacks$executeElapsed() + shiny:::flushReact() + shiny:::flushPendingSessions() +} + +# Serialise WS response and send to main thread for handling +.send_ws <- function (message) { + webr::eval_js( + paste0( + "chan.write({", + "type: '_webR_httpuv_WSResponse', ", + "data: ", jsonlite::serializeJSON(message), + "});" + ) + ) +} + +# Create a rook input stream object with a vector of bytes as its source +.RawReader <- setRefClass( + "RawReader", + fields = c("con", "length"), + methods = list( + init = function(bytes) { + con <<- rawConnection(bytes, "rb") + length <<- length(bytes) + }, + read = function(l = -1L) { + if (l < 0) l <- length + readBin(con, "raw", size = 1, n = l) + }, + read_lines = function(l = -1L) { + readLines(con, n = l) + }, + rewind = function() { + seek(con, 0) + }, + destroy = function() { + close(con) + } + ) +) + +# Save a set of Shiny app files from Shinylive to the webR VFS +.save_files <- function(files, appDir) { for (name in names(files)) { filename <- file.path(appDir, name) path <- dirname(filename) @@ -157,18 +211,15 @@ const load_r_pre = } } -.stop_app <- function() { - webr::eval_js(" - chan.write({ - type: '_webR_httpuv_WSResponse', - data: { handle: '1', binary: false, type: 'websocket.close', message: 'stopped' } - }); - ") - shiny::stopApp() +.stop_app <- function(appName) { + .send_ws(c("websocket.close", appName, "")) + assign(appName, NULL, envir = .shiny_app_registry) + invisible(0) } -.start_app <- function (appDir) { - shiny::runApp(appDir, port=0, host=NULL) +.start_app <- function(appName, appDir) { + app <- .shiny_to_httpuv(appDir) + assign(appName, app, envir = .shiny_app_registry) invisible(0) } diff --git a/src/messageporthttp.ts b/src/messageporthttp.ts index 5169fab3..eac97b5f 100644 --- a/src/messageporthttp.ts +++ b/src/messageporthttp.ts @@ -1,3 +1,4 @@ +import { isRList } from "webr"; import { AwaitableQueue } from "./awaitable-queue"; import { loadPyodide } from "./pyodide/pyodide"; import type { PyProxyCallable } from "./pyodide/pyodide"; @@ -227,7 +228,6 @@ function asgiBodyToArray(body: any): Uint8Array { // webR // ============================================================================= import { WebRProxy } from "./webr-proxy"; -import { makeRandomKey } from "./utils"; export async function makeHttpuvRequest( scope: ASGIHTTPRequestScope, @@ -283,17 +283,16 @@ export async function makeHttpuvRequest( more_body: false, }); } - await handleHttpuvRequests(scope, webRProxy, fromClient, toClient); + await handleHttpuvRequests(scope, appName, webRProxy, fromClient, toClient); } async function handleHttpuvRequests( scope: ASGIHTTPRequestScope, + appName: string, webRProxy: WebRProxy, fromClient: () => Promise>, toClient: (event: Record) => Promise ){ - const uuid = makeRandomKey(20); - webRProxy.toClientCache[uuid] = toClient; let body = new Uint8Array(0); const shelter = await new webRProxy.webR.Shelter(); for (;;) { @@ -313,24 +312,36 @@ async function handleHttpuvRequests( if (!request.more_body) { try { const bytes = await new shelter.RRaw(Array.from(body)); - const env = await new shelter.REnvironment({ bytes }); - await webRProxy.runRAsync(` - onRequest <- options('webr_httpuv_onRequest')[[1]] - if (!is.null(onRequest)) { - reader <- .RawReader$new() - reader$init(bytes) - onRequest( - list( - PATH_INFO = "${scope.path}", - REQUEST_METHOD = "${scope.method}", - QUERY_STRING = "${scope.query_string}", - UUID = "${uuid}", - rook.input = reader + const env = await new shelter.REnvironment({ bytes, appName }); + const resp = await webRProxy.webR.evalR(` + reader <- .RawReader$new() + reader$init(bytes) + tryCatch( + { + app <- get(appName, env = .shiny_app_registry) + app$call( + list( + PATH_INFO = "${scope.path}", + REQUEST_METHOD = "${scope.method}", + QUERY_STRING = "${scope.query_string}", + rook.input = reader + ) ) - ) - reader$destroy() - } - `, { env }) + }, + finally = { + reader$destroy() + } + ) + `, { env, captureConditions: false, captureStreams: false }); + + if (!isRList(resp)) { + throw new Error( + `Unexpected response type: "${resp.type()}", expected "list".` + ); + } + + const event = await resp.toObject({ depth: 0 }); + await toClient(event); } finally { shelter.purge(); } diff --git a/src/messageportwebsocket-channel.ts b/src/messageportwebsocket-channel.ts index a44e521a..799432f5 100644 --- a/src/messageportwebsocket-channel.ts +++ b/src/messageportwebsocket-channel.ts @@ -1,3 +1,4 @@ +import { RFunction } from "webr"; import { AwaitableQueue } from "./awaitable-queue"; import { MessagePortWebSocket } from "./messageportwebsocket"; import { loadPyodide } from "./pyodide/pyodide"; @@ -69,6 +70,7 @@ async function connect( conn.send(event.text ?? event.bytes); } else if (event.type === "websocket.close") { conn.close(event.code, event.reason); + fromClientQueue.enqueue({ type: "websocket.disconnect" }); } else { conn.close(1002, "ASGI protocol error"); throw new Error(`Unhandled ASGI event: ${event.type}`); @@ -111,7 +113,10 @@ export async function openChannelHttpuv( webRProxy: WebRProxy, ): Promise { const conn = new MessagePortWebSocket(clientPort); + const shelter = await new webRProxy.webR.Shelter(); let connected = false; + let onWSMessage: RFunction | undefined; + let onWSClose: RFunction | undefined; async function toClient(event: Record): Promise { if (!connected) { @@ -129,7 +134,7 @@ export async function openChannelHttpuv( throw new Error(`Unhandled ASGI event: ${event.type}`); } } - webRProxy.toClientCache['ws'] = toClient; + webRProxy.toClientCache[appName] = toClient; const fromClientQueue = new AwaitableQueue>(); fromClientQueue.enqueue({ type: "websocket.connect" }); @@ -150,33 +155,51 @@ export async function openChannelHttpuv( console.error(e); }); + // Infinite async loop until connection is closed. for(;;){ const msg = await fromClientQueue.dequeue(); switch(msg.type){ - case 'websocket.connect': - webRProxy.runRAsync(` - onWSOpen <- options('webr_httpuv_onWSOpen')[[1]] - if (!is.null(onWSOpen)) { - onWSOpen(1, list(handle = 1)) - } - `) + case 'websocket.connect': { + const callbacks = await webRProxy.webR.evalR(` + app <- get(appName, env = .shiny_app_registry) + onWSMessage <- NULL + onWSClose <- NULL + ws <- list( + req = list(), + onMessage = function(func) { + onWSMessage <<- func + }, + onClose = function(func) { + onWSClose <<- func + }, + send = function(msg) { + .send_ws(c("websocket.send", appName, msg)) + }, + close = function(msg) { + .send_ws(c("websocket.close", appName, msg)) + } + ) + app$onWSOpen(ws) + list(onWSMessage = onWSMessage, onWSClose = onWSClose) + `, { env: { appName } }); + onWSMessage = await callbacks.get('onWSMessage') as RFunction; + onWSClose = await callbacks.get('onWSClose') as RFunction; break; - case 'websocket.receive': - webRProxy.runRAsync(` - onWSMessage <- options('webr_httpuv_onWSMessage')[[1]] - if (!is.null(onWSMessage)) { - onWSMessage(1, FALSE, '${msg.text}') + } + case 'websocket.receive': { + const text = await new shelter.RCharacter(msg.text); + try { + if (typeof onWSMessage !== 'undefined') { + onWSMessage(webRProxy.webR.objs.false, text); } - `) + } finally { + shelter.purge(); + } break; + } case 'websocket.disconnect': - webRProxy.runRAsync(` - onWSClose <- options('webr_httpuv_onWSClose')[[1]] - if (!is.null(onWSClose)) { - onWSClose(1) - } - `) - break; + if (onWSClose) onWSClose(); + return; default: console.warn(`Unhandled websocket message of type "${msg.type}".`) return; diff --git a/src/webr-proxy.ts b/src/webr-proxy.ts index 8948f45e..c544c0b6 100644 --- a/src/webr-proxy.ts +++ b/src/webr-proxy.ts @@ -95,18 +95,12 @@ class WebRWorkerProxy implements WebRProxy { this.prompt.resolve(output.data); } break; - case '_webR_httpuv_TcpResponse': { - const msg = output as { - data?: any; - type: string; - uuid: string; - } - this.toClientCache[msg.uuid](msg.data); - break; - } case '_webR_httpuv_WSResponse': { - const toClient = this.toClientCache['ws']; - if (typeof toClient !== 'undefined') toClient(output.data); + const type = output.data.value[0]; + const appName = output.data.value[1] + const message = output.data.value[2]; + const toClient = this.toClientCache[appName]; + if (typeof toClient !== 'undefined') toClient({ type, message }); break; } default: From a9e327feb61dfa31f6fe92c6813b67642389ac6e Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 21 Aug 2023 15:14:07 +0100 Subject: [PATCH 15/21] Update webR to 0.2.0 --- package.json | 2 +- yarn.lock | 96 +++++++++++++++++++++++++++++----------------------- 2 files changed, 54 insertions(+), 44 deletions(-) diff --git a/package.json b/package.json index 2983969d..74f5fad7 100644 --- a/package.json +++ b/package.json @@ -135,6 +135,6 @@ }, "packageManager": "yarn@3.2.3", "dependencies": { - "webr": "0.2.0-rc.0" + "webr": "0.2.0" } } diff --git a/yarn.lock b/yarn.lock index 5b59825d..b35bdc1e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -592,13 +592,13 @@ __metadata: linkType: hard "@codemirror/view@npm:^6.15.0": - version: 6.15.3 - resolution: "@codemirror/view@npm:6.15.3" + version: 6.16.0 + resolution: "@codemirror/view@npm:6.16.0" dependencies: "@codemirror/state": ^6.1.4 style-mod: ^4.0.0 w3c-keyname: ^2.2.4 - checksum: 048949b1b493a962904a7f77661a939f7c1893a7381022756a135f5dd8daf667f498be1b81da9c37c0e8de85b078ad987c2f75318385c520ed83c95da6313e95 + checksum: 54d412b5159716c8a1a9c46fa04ff083e68a663cb887e6e2a4ca86fe9c3930d5255200fe84c65620e0a442f62dc2c13df277bcd1d4eef2e11e3c4e124fcf9d38 languageName: node linkType: hard @@ -5220,80 +5220,90 @@ __metadata: languageName: node linkType: hard -"lightningcss-darwin-arm64@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-darwin-arm64@npm:1.21.5" +"lightningcss-darwin-arm64@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-darwin-arm64@npm:1.21.7" conditions: os=darwin & cpu=arm64 languageName: node linkType: hard -"lightningcss-darwin-x64@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-darwin-x64@npm:1.21.5" +"lightningcss-darwin-x64@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-darwin-x64@npm:1.21.7" conditions: os=darwin & cpu=x64 languageName: node linkType: hard -"lightningcss-linux-arm-gnueabihf@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-linux-arm-gnueabihf@npm:1.21.5" +"lightningcss-freebsd-x64@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-freebsd-x64@npm:1.21.7" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"lightningcss-linux-arm-gnueabihf@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-linux-arm-gnueabihf@npm:1.21.7" conditions: os=linux & cpu=arm languageName: node linkType: hard -"lightningcss-linux-arm64-gnu@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-linux-arm64-gnu@npm:1.21.5" +"lightningcss-linux-arm64-gnu@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-linux-arm64-gnu@npm:1.21.7" conditions: os=linux & cpu=arm64 & libc=glibc languageName: node linkType: hard -"lightningcss-linux-arm64-musl@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-linux-arm64-musl@npm:1.21.5" +"lightningcss-linux-arm64-musl@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-linux-arm64-musl@npm:1.21.7" conditions: os=linux & cpu=arm64 & libc=musl languageName: node linkType: hard -"lightningcss-linux-x64-gnu@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-linux-x64-gnu@npm:1.21.5" +"lightningcss-linux-x64-gnu@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-linux-x64-gnu@npm:1.21.7" conditions: os=linux & cpu=x64 & libc=glibc languageName: node linkType: hard -"lightningcss-linux-x64-musl@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-linux-x64-musl@npm:1.21.5" +"lightningcss-linux-x64-musl@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-linux-x64-musl@npm:1.21.7" conditions: os=linux & cpu=x64 & libc=musl languageName: node linkType: hard -"lightningcss-win32-x64-msvc@npm:1.21.5": - version: 1.21.5 - resolution: "lightningcss-win32-x64-msvc@npm:1.21.5" +"lightningcss-win32-x64-msvc@npm:1.21.7": + version: 1.21.7 + resolution: "lightningcss-win32-x64-msvc@npm:1.21.7" conditions: os=win32 & cpu=x64 languageName: node linkType: hard "lightningcss@npm:^1.21.5": - version: 1.21.5 - resolution: "lightningcss@npm:1.21.5" + version: 1.21.7 + resolution: "lightningcss@npm:1.21.7" dependencies: detect-libc: ^1.0.3 - lightningcss-darwin-arm64: 1.21.5 - lightningcss-darwin-x64: 1.21.5 - lightningcss-linux-arm-gnueabihf: 1.21.5 - lightningcss-linux-arm64-gnu: 1.21.5 - lightningcss-linux-arm64-musl: 1.21.5 - lightningcss-linux-x64-gnu: 1.21.5 - lightningcss-linux-x64-musl: 1.21.5 - lightningcss-win32-x64-msvc: 1.21.5 + lightningcss-darwin-arm64: 1.21.7 + lightningcss-darwin-x64: 1.21.7 + lightningcss-freebsd-x64: 1.21.7 + lightningcss-linux-arm-gnueabihf: 1.21.7 + lightningcss-linux-arm64-gnu: 1.21.7 + lightningcss-linux-arm64-musl: 1.21.7 + lightningcss-linux-x64-gnu: 1.21.7 + lightningcss-linux-x64-musl: 1.21.7 + lightningcss-win32-x64-msvc: 1.21.7 dependenciesMeta: lightningcss-darwin-arm64: optional: true lightningcss-darwin-x64: optional: true + lightningcss-freebsd-x64: + optional: true lightningcss-linux-arm-gnueabihf: optional: true lightningcss-linux-arm64-gnu: @@ -5306,7 +5316,7 @@ __metadata: optional: true lightningcss-win32-x64-msvc: optional: true - checksum: fcfb80302740c275fea8dc6ebc1a31da681321d92f0ddb33deb71037bb3dee08b8143ee03ddad8cef39c7ba4000447b362c9295d264deec124c9a206a848fef4 + checksum: 354e3549cc3942ba2369871473cc6ff7b63f09ccf1ca2d6f52b809f97e59acc8c091c227df41dc135034e70f281f94a25b0847e18de619c12552a8cb9a246406 languageName: node linkType: hard @@ -6542,7 +6552,7 @@ __metadata: tsx: ^3.12.7 typescript: ^5.1.3 vscode-languageserver-protocol: ^3.17.3 - webr: 0.2.0-rc.0 + webr: 0.2.0 xterm: ^5.2.1 xterm-addon-fit: ^0.7.0 xterm-readline: ^1.1.1 @@ -7224,9 +7234,9 @@ __metadata: languageName: node linkType: hard -"webr@npm:0.2.0-rc.0": - version: 0.2.0-rc.0 - resolution: "webr@npm:0.2.0-rc.0" +"webr@npm:0.2.0": + version: 0.2.0 + resolution: "webr@npm:0.2.0" dependencies: "@codemirror/autocomplete": ^6.8.1 "@codemirror/commands": ^6.2.4 @@ -7245,7 +7255,7 @@ __metadata: xterm: ^5.1.0 xterm-addon-fit: ^0.7.0 xterm-readline: ^1.1.1 - checksum: 51cdc7d75256845415eb41672329531e74efa37ee6d016a1bc3b6a4dba1897c7a0eafa0b89655450adc88986d8f3c64be18b547a1d2532f184348591c7607df2 + checksum: 97e638a0af807bba9933f0b98eb370b64170da28e467e0e669bd326df745562fd62cf31e9fe3c481c22a3be960373d9d7b1cc46a283399ec62f3fe1ddcea2b76 languageName: node linkType: hard From b9b0057b92feb8c384da2a9c4e100df57df8e573 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 21 Aug 2023 16:12:31 +0100 Subject: [PATCH 16/21] Only insert COI headers if requested in URL param With this commit the Service Worker will only inject the HTTP headers required for Cross-Origin Isolation if the URL or referrer URL contains the parameter `coi=1`. Now, Shinylive for Python is not served with COI headers injected by default. This fixes the Map example when running under Pyodide. If an app using the webR engine is run, and the page is not Cross-Origin Isolated, the parameter `coi=1` is added to the current location URL and the page refreshed. With this, using webR without the required COI headers from the server is handled automatically. --- scripts/build.ts | 14 ++++++++------ src/Components/App.tsx | 8 ++++++++ src/messageporthttp.ts | 5 +---- src/shinylive-sw.ts | 17 +++++++++++++++-- 4 files changed, 32 insertions(+), 12 deletions(-) diff --git a/scripts/build.ts b/scripts/build.ts index 9bf83379..7a6bd3c3 100644 --- a/scripts/build.ts +++ b/scripts/build.ts @@ -236,12 +236,14 @@ if (serve) { http.request( { hostname: "0.0.0.0", port: 3001, path: url, method, headers }, (proxyRes) => { - proxyRes.headers = { - ...proxyRes.headers, - "cross-origin-opener-policy": "same-origin", - "cross-origin-embedder-policy": "require-corp", - "cross-origin-resource-policy": "cross-origin", - }; + if (appEngine === 'r') { + proxyRes.headers = { + ...proxyRes.headers, + "cross-origin-opener-policy": "same-origin", + "cross-origin-embedder-policy": "require-corp", + "cross-origin-resource-policy": "cross-origin", + }; + } if (url === "/shinylive/shinylive.js") { // JS code for does auto-reloading. We'll inject it into // shinylive.js as it's sent. diff --git a/src/Components/App.tsx b/src/Components/App.tsx index c7e4df66..b0fdd84a 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -539,6 +539,14 @@ export function runApp( let startFiles: undefined | FileContentJson[] | FileContent[] = opts.startFiles; + // If we require webR, but not Cross-Origin Isolated, ask the service worker + // to make it so + const url = new URL(location.href); + if (appEngine == 'r' && !url.searchParams.get('coi') && !crossOriginIsolated) { + url.searchParams.set('coi', '1'); + location.assign(url.search); + } + (async () => { if (startFiles === undefined) { // Use the URL hash to determine what files to start with. diff --git a/src/messageporthttp.ts b/src/messageporthttp.ts index eac97b5f..e60da6ed 100644 --- a/src/messageporthttp.ts +++ b/src/messageporthttp.ts @@ -212,10 +212,7 @@ function asgiHeadersToRecord(headers: any): Record { headers = headers.map(([key, val]: [Uint8Array, Uint8Array]) => { return [uint8ArrayToString(key), uint8ArrayToString(val)]; }); - return Object.assign(Object.fromEntries(headers), { - "cross-origin-embedder-policy": "require-corp", - "cross-origin-resource-policy": "cross-origin", - }); + return Object.fromEntries(headers); } function asgiBodyToArray(body: any): Uint8Array { diff --git a/src/shinylive-sw.ts b/src/shinylive-sw.ts index 068bffb3..a3aa7533 100644 --- a/src/shinylive-sw.ts +++ b/src/shinylive-sw.ts @@ -83,6 +83,9 @@ self.addEventListener("fetch", function (event): void { return; } + const coiRequested = url.searchParams.get('coi') === '1' + || request.referrer.includes('coi=1'); + // Fetches that are prepended with /app_/ need to be proxied to pyodide. // We use fetchASGI. const appPathRegex = /.*\/(app_[^/]+\/)/; @@ -116,7 +119,7 @@ self.addEventListener("fetch", function (event): void { const filter = isAppRoot ? injectSocketFilter : identityFilter; const blob = await request.blob(); - return fetchASGI( + const resp = await fetchASGI( apps[m_appPath[1]], new Request(url.toString(), { method: request.method, @@ -133,6 +136,11 @@ self.addEventListener("fetch", function (event): void { undefined, filter ); + if (coiRequested) { + return addCoiHeaders(resp); + } else { + return resp; + } })() ); return; @@ -179,7 +187,12 @@ self.addEventListener("fetch", function (event): void { } event.respondWith((async (): Promise => { - return addCoiHeaders(await fetch(request)); + const resp = await fetch(request); + if (coiRequested) { + return addCoiHeaders(resp); + } else { + return resp; + } })()); }); From e92dfa9f7c774a3886dc80d133d032044198b5f5 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 22 Aug 2023 10:13:28 +0100 Subject: [PATCH 17/21] Support R blocks and apps in addition to Python --- src/hooks/usePyodide.tsx | 9 +++++++-- src/hooks/useWebR.tsx | 9 +++++++-- src/parse-codeblock.ts | 7 ++++--- src/run-python-blocks.ts | 10 ++++++---- 4 files changed, 24 insertions(+), 11 deletions(-) diff --git a/src/hooks/usePyodide.tsx b/src/hooks/usePyodide.tsx index 5a31535d..cc6ca403 100644 --- a/src/hooks/usePyodide.tsx +++ b/src/hooks/usePyodide.tsx @@ -402,10 +402,15 @@ let channelListenerRegistered = false; function ensureOpenChannelListener(pyodideProxy: PyodideProxy): void { if (channelListenerRegistered) return; - window.addEventListener("message", (event) => { + window.addEventListener("message", async (event) => { const msg = event.data; if (msg.type === "openChannel") { - pyodideProxy.openChannel(msg.path, msg.appName, event.ports[0]); + const appExists = await pyodideProxy.runPyAsync(` + "${msg.appName}" in _shiny_app_registry + `, { returnResult: "value" }); + if (appExists) { + pyodideProxy.openChannel(msg.path, msg.appName, event.ports[0]); + } } }); diff --git a/src/hooks/useWebR.tsx b/src/hooks/useWebR.tsx index b653a853..386e2db9 100644 --- a/src/hooks/useWebR.tsx +++ b/src/hooks/useWebR.tsx @@ -116,10 +116,15 @@ let channelListenerRegistered = false; function ensureOpenChannelListener(webRProxy: WebRProxy): void { if (channelListenerRegistered) return; - window.addEventListener("message", (event) => { + window.addEventListener("message", async (event) => { const msg = event.data; if (msg.type === "openChannel") { - webRProxy.openChannel(msg.path, msg.appName, event.ports[0]); + const appExists = await webRProxy.runRAsync(` + exists("${msg.appName}", envir = .shiny_app_registry) + `); + if (await appExists.toBoolean()) { + webRProxy.openChannel(msg.path, msg.appName, event.ports[0]); + } } }); diff --git a/src/parse-codeblock.ts b/src/parse-codeblock.ts index 93317073..3d39c523 100644 --- a/src/parse-codeblock.ts +++ b/src/parse-codeblock.ts @@ -1,3 +1,4 @@ +import { AppEngine } from "./Components/App"; import type { FileContent } from "./Components/filecontent"; import { load as yamlLoad } from "js-yaml"; @@ -37,7 +38,7 @@ export type QuartoArgs = { * iVBORw0KGgoAAAANSUhEUgAAACgAAAAuCAYAAABap1twAAAABGdBTUEAALGPC ... * ------------------------------ */ -export function parseCodeBlock(codeblock: string | string[]): { +export function parseCodeBlock(codeblock: string | string[], engine: AppEngine): { files: FileContent[]; quartoArgs: Required; } { @@ -64,8 +65,8 @@ export function parseCodeBlock(codeblock: string | string[]): { "Shinylive application code blocks must have a '#| standalone: true' argument. In the future other values will be supported." ); } - // For shiny apps, the default filename is "app.py". - defaultFilename = "app.py"; + // For shiny apps, the default filename is "app.py" or "app.R". + defaultFilename = engine === 'python' ? 'app.py' : 'app.R'; } else { // In the case of editor-terminal and editor-cell components... if (quartoArgs.standalone !== false) { diff --git a/src/run-python-blocks.ts b/src/run-python-blocks.ts index 73c69d08..91947b33 100644 --- a/src/run-python-blocks.ts +++ b/src/run-python-blocks.ts @@ -1,4 +1,4 @@ -import type { AppMode } from "./Components/App"; +import type { AppEngine, AppMode } from "./Components/App"; import { parseCodeBlock } from "./parse-codeblock"; import type { Component } from "./parse-codeblock"; // @ts-expect-error: This import is _not_ bundled. It would be nice to be able @@ -11,23 +11,25 @@ import { runApp } from "./shinylive.js"; // important that they're selected in the order they appear in the page, so that // we execute them in the correct order. const blocks: NodeListOf = - document.querySelectorAll(".shinylive-python"); + document.querySelectorAll(".shinylive-python, .shinylive-r"); blocks.forEach((block) => { const container = document.createElement("div"); container.className = "shinylive-wrapper"; + const engine: AppEngine = block.dataset.engine === 'r' ? 'r' : 'python'; + // Copy over explicitly-set style properties. container.style.cssText = block.style.cssText; block.parentNode!.replaceChild(container, block); - const { files, quartoArgs } = parseCodeBlock(block.innerText); + const { files, quartoArgs } = parseCodeBlock(block.innerText, engine); const appMode = convertComponentArrayToAppMode(quartoArgs.components); const opts = { startFiles: files, ...quartoArgs }; - runApp(container, appMode, opts); + runApp(container, appMode, opts, engine); }); /** From 456b7bfdb758b2ec3f9af71c9315f059765947dc Mon Sep 17 00:00:00 2001 From: George Stagg Date: Wed, 26 Jul 2023 11:05:36 +0100 Subject: [PATCH 18/21] Don't show the "reformat code" button for webR --- src/Components/App.tsx | 5 +++++ src/Components/Editor.tsx | 8 +++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/Components/App.tsx b/src/Components/App.tsx index b0fdd84a..7a178b49 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -348,6 +348,7 @@ export function App({ runOnLoad={currentFiles.some((file) => file.name === "app.py" || file.name === "app.R" || file.name === "server.R" )} + appEngine={appEngine} /> file.name === "app.py" || file.name === "app.R" || file.name === "server.R" )} + appEngine={appEngine} /> >; @@ -88,6 +89,7 @@ export default function Editor({ lineNumbers?: boolean; showHeaderBar?: boolean; floatingButtons?: boolean; + appEngine: AppEngine; }) { // In the future, instead of directly instantiating the PyrightClient, it // would make sense to abstract it out to a class which in turn can run @@ -464,7 +466,7 @@ export default function Editor({ // =========================================================================== // Buttons // =========================================================================== - const formatCodeButton = ( + const formatCodeButton = appEngine === 'python' ? ( - ); + ) : null; const formatCode = React.useCallback(async () => { if (!cmView) return; From 81f92b9da2495859a8728c96817f91b256be433c Mon Sep 17 00:00:00 2001 From: George Stagg Date: Tue, 22 Aug 2023 15:05:27 +0100 Subject: [PATCH 19/21] Fix file downloading in R Shiny apps --- src/messageporthttp.ts | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/src/messageporthttp.ts b/src/messageporthttp.ts index e60da6ed..74788d92 100644 --- a/src/messageporthttp.ts +++ b/src/messageporthttp.ts @@ -1,4 +1,3 @@ -import { isRList } from "webr"; import { AwaitableQueue } from "./awaitable-queue"; import { loadPyodide } from "./pyodide/pyodide"; import type { PyProxyCallable } from "./pyodide/pyodide"; @@ -224,6 +223,7 @@ function asgiBodyToArray(body: any): Uint8Array { // ============================================================================= // webR // ============================================================================= +import { RList, isRList } from "webr"; import { WebRProxy } from "./webr-proxy"; export async function makeHttpuvRequest( @@ -310,7 +310,7 @@ async function handleHttpuvRequests( try { const bytes = await new shelter.RRaw(Array.from(body)); const env = await new shelter.REnvironment({ bytes, appName }); - const resp = await webRProxy.webR.evalR(` + const httpuvResp = await webRProxy.webR.evalR(` reader <- .RawReader$new() reader$init(bytes) tryCatch( @@ -331,14 +331,27 @@ async function handleHttpuvRequests( ) `, { env, captureConditions: false, captureStreams: false }); - if (!isRList(resp)) { + if (!isRList(httpuvResp)) { throw new Error( - `Unexpected response type: "${resp.type()}", expected "list".` + `Unexpected response type: "${httpuvResp.type()}", expected "list".` ); } - const event = await resp.toObject({ depth: 0 }); - await toClient(event); + // If the response from httpuv is pointing to a temporary file to serve, + // grab that file's content and return it in the actual response. + let resp: RList = httpuvResp; + const httpuvBody = await httpuvResp.get('body'); + if (await httpuvBody.type() === 'list') { + const file = await httpuvResp.pluck('body', 'file'); + if (file) { + const filename = await file.toString(); + const content = await webRProxy.webR.FS.readFile(filename); + const raw = await new shelter.RRaw(Array.from(content)); + resp = await httpuvResp.set('body', raw) as RList; + } + } + + await toClient(await resp.toObject({ depth: 0 })); } finally { shelter.purge(); } From 289a32f739f79a23ca91bdfa7247ceadf9cfd92a Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 21 Aug 2023 15:12:42 +0100 Subject: [PATCH 20/21] Fix download button in R download example --- examples/r/010-download/app.R | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/r/010-download/app.R b/examples/r/010-download/app.R index 745ffc36..9c773473 100644 --- a/examples/r/010-download/app.R +++ b/examples/r/010-download/app.R @@ -2,9 +2,9 @@ library(shiny) # Workaround for Chromium Issue 468227 downloadButton <- function(...) { - tag <- shiny::downloadButton("downloadData", "Download") - tag$attribs$download <- NULL - tag + tag <- shiny::downloadButton(...) + tag$attribs$download <- NULL + tag } # Define UI for data download app ---- From 0be3512ef487fa009524b0dfba66252d253a18a5 Mon Sep 17 00:00:00 2001 From: George Stagg Date: Mon, 4 Sep 2023 16:30:28 +0100 Subject: [PATCH 21/21] Reorganise examples/index.json hierarchy --- examples/index.json | 206 ++++++++++++++--------------- scripts/build_examples_json.ts | 10 +- src/Components/App.tsx | 4 +- src/Components/ExampleSelector.tsx | 3 +- src/examples.ts | 33 +++-- 5 files changed, 136 insertions(+), 120 deletions(-) diff --git a/examples/index.json b/examples/index.json index 23304d95..dfde82c6 100644 --- a/examples/index.json +++ b/examples/index.json @@ -1,113 +1,113 @@ [ { - "category": "Basic", "engine": "python", - "apps": ["basic_app", "app_with_plot"] - }, - { - "category": "Featured", - "engine": "python", - "apps": [ - "cpuinfo", - "orbit", - "regularization", - "wordle", - "plotly", - "ipyleaflet", - "camera" - ] - }, - { - "category": "Intermediate", - "engine": "python", - "apps": [ - "multiple_source_files", - "read_local_csv_file", - "file_upload", - "file_download", - "insert_ui", - "input_update", - "modules", - "extra_packages", - "static_content", - "fetch", - "ipywidgets" - ] - }, - { - "category": "Inputs", - "engine": "python", - "apps": [ - "input_text", - "input_numeric", - "input_slider", - "input_checkbox", - "input_switch", - "input_checkbox_group", - "input_select", - "input_radio", - "input_text_area", - "input_date", - "input_date_range", - "input_password" - ] - }, - { - "category": "Outputs", - "engine": "python", - "apps": [ - "output_text", - "output_text_verbatim", - "output_ui", - "output_plot", - "output_table", - "output_data_frame_grid" + "examples": [ + { + "category": "Basic", + "apps": ["basic_app", "app_with_plot"] + }, + { + "category": "Featured", + "apps": [ + "cpuinfo", + "orbit", + "regularization", + "wordle", + "plotly", + "ipyleaflet", + "camera" + ] + }, + { + "category": "Intermediate", + "apps": [ + "multiple_source_files", + "read_local_csv_file", + "file_upload", + "file_download", + "insert_ui", + "input_update", + "modules", + "extra_packages", + "static_content", + "fetch", + "ipywidgets" + ] + }, + { + "category": "Inputs", + "apps": [ + "input_text", + "input_numeric", + "input_slider", + "input_checkbox", + "input_switch", + "input_checkbox_group", + "input_select", + "input_radio", + "input_text_area", + "input_date", + "input_date_range", + "input_password" + ] + }, + { + "category": "Outputs", + "apps": [ + "output_text", + "output_text_verbatim", + "output_ui", + "output_plot", + "output_table", + "output_data_frame_grid" + ] + }, + { + "category": "Layout", + "apps": ["shinyswatch", "layout_sidebar", "layout_two_column"] + }, + { + "category": "Reactivity", + "apps": [ + "reactive_event", + "reactive_effect", + "reactive_calc", + "reactive_value" + ] + }, + { + "category": "Interactive plots", + "apps": [ + "plot_interact_basic", + "plot_interact_select", + "plot_interact_exclude" + ] + }, + { + "category": "Non-Apps", + "apps": ["hello_world"] + } ] }, { - "category": "Layout", - "engine": "python", - "apps": ["shinyswatch", "layout_sidebar", "layout_two_column"] - }, - { - "category": "Reactivity", - "engine": "python", - "apps": [ - "reactive_event", - "reactive_effect", - "reactive_calc", - "reactive_value" - ] - }, - { - "category": "Interactive plots", - "engine": "python", - "apps": [ - "plot_interact_basic", - "plot_interact_select", - "plot_interact_exclude" - ] - }, - { - "category": "Non-Apps", - "engine": "python", - "apps": ["hello_world"] - }, - { - "category": "R examples", "engine": "r", - "apps": [ - "001-hello", - "002-text", - "003-reactivity", - "004-mpg", - "005-sliders", - "006-tabsets", - "007-widgets", - "008-html", - "009-upload", - "010-download", - "011-timer" + "examples": [ + { + "category": "R examples", + "apps": [ + "001-hello", + "002-text", + "003-reactivity", + "004-mpg", + "005-sliders", + "006-tabsets", + "007-widgets", + "008-html", + "009-upload", + "010-download", + "011-timer" + ] + } ] } ] diff --git a/scripts/build_examples_json.ts b/scripts/build_examples_json.ts index e30837a7..dfae2a3f 100644 --- a/scripts/build_examples_json.ts +++ b/scripts/build_examples_json.ts @@ -91,10 +91,14 @@ export default function buildExamples(examplesDir: string, buildDir: string) { fs.writeFileSync( outputFile, JSON.stringify( - ordering.map(({ category, engine, apps }) => ({ - category, + ordering.map(({ engine, examples }) => ({ engine, - apps: apps.map((app) => parseApp(app, engine)), + examples: examples.map(({ category, apps }) => { + return { + category, + apps: apps.map((app) => parseApp(app, engine)), + }; + }), })), null, 2 diff --git a/src/Components/App.tsx b/src/Components/App.tsx index 7a178b49..900cd5a5 100644 --- a/src/Components/App.tsx +++ b/src/Components/App.tsx @@ -594,9 +594,7 @@ export function runApp( if (value === "") exampleName = key; } - const exampleCategories = (await getExampleCategories()).filter( - (cat) => cat.engine === appEngine - ); + const exampleCategories = (await getExampleCategories(appEngine)); let pos = findExampleByTitle(exampleName, exampleCategories); if (pos) { opts.selectedExample = exampleName; diff --git a/src/Components/ExampleSelector.tsx b/src/Components/ExampleSelector.tsx index 05a0cb96..c51d25e8 100644 --- a/src/Components/ExampleSelector.tsx +++ b/src/Components/ExampleSelector.tsx @@ -32,8 +32,7 @@ export function ExampleSelector({ React.useEffect(() => { (async () => { - const categories = await getExampleCategories(); - setExampleCategories(categories.filter((cat) => cat.engine === appEngine)); + setExampleCategories(await getExampleCategories(appEngine)); })(); }, [appEngine]); diff --git a/src/examples.ts b/src/examples.ts index b2112673..9b99d4e1 100644 --- a/src/examples.ts +++ b/src/examples.ts @@ -1,3 +1,4 @@ +import { AppEngine } from "./Components/App"; import { FCJSONtoFC, FileContent, @@ -12,15 +13,21 @@ export type ExampleItemJson = { // For examples/index.json export type ExampleCategoryIndexJson = { - category: string; engine: string; - apps: string[]; + examples: { + category: string; + apps: string[]; + }[]; }; // For examples.json +export type ExampleIndexJson = { + engine: string; + examples: ExampleCategoryJson[]; +}; + export type ExampleCategoryJson = { category: string; - engine: string; apps: ExampleItemJson[]; }; @@ -32,7 +39,6 @@ export type ExampleItem = { export type ExampleCategory = { category: string; - engine: string; apps: ExampleItem[]; }; @@ -43,16 +49,26 @@ export type ExamplePosition = { let exampleCategories: ExampleCategory[] | null = null; -export async function getExampleCategories(): Promise { +export async function getExampleCategories( + engine: AppEngine +): Promise { if (exampleCategories) { return exampleCategories; } const response = await fetch("../shinylive/examples.json"); - const exampleCategoriesJson = - (await response.json()) as ExampleCategoryJson[]; + const exampleIndexJson = + (await response.json()) as ExampleIndexJson[]; + + const exampleCategoriesJson = exampleIndexJson.find( + (value) => value.engine === engine + ); + + if (!exampleCategoriesJson) { + throw new Error(`No examples found for app engine ${engine}`); + } - exampleCategories = exampleCategoriesJson.map( + exampleCategories = exampleCategoriesJson.examples.map( exampleCategoryJsonToExampleCategory ); @@ -96,7 +112,6 @@ function exampleCategoryJsonToExampleCategory( ): ExampleCategory { return { category: x.category, - engine: x.engine, apps: x.apps.map(exampleItemJsonToExampleItem), }; }