diff --git a/apps/remix-ide/src/app.js b/apps/remix-ide/src/app.js index 27e2e5de88..c7077d2c38 100644 --- a/apps/remix-ide/src/app.js +++ b/apps/remix-ide/src/app.js @@ -45,6 +45,7 @@ import { FileDecorator } from './app/plugins/file-decorator' import { CodeFormat } from './app/plugins/code-format' import { SolidityUmlGen } from './app/plugins/solidity-umlgen' import { ContractFlattener } from './app/plugins/contractFlattener' +import { WebContainerPlugin } from './app/plugins/web-container/web-container' const isElectron = require('is-electron') @@ -187,6 +188,9 @@ class AppComponent { // ----------------- ContractFlattener ---------------------------- const contractFlattener = new ContractFlattener() + // ----------------- WebContainerPlugin ---------------------------- + const webContainerPlugin = new WebContainerPlugin() + // ----------------- import content service ------------------------ const contentImport = new CompilerImports() @@ -302,7 +306,8 @@ class AppComponent { search, solidityumlgen, contractFlattener, - solidityScript + solidityScript, + webContainerPlugin ]) // LAYOUT & SYSTEM VIEWS @@ -419,6 +424,7 @@ class AppComponent { await this.appManager.activatePlugin(['settings']) await this.appManager.activatePlugin(['walkthrough', 'storage', 'search', 'compileAndRun', 'recorder']) await this.appManager.activatePlugin(['solidity-script']) + await this.appManager.activatePlugin(['web-container-plugin']) this.appManager.on( 'filePanel', diff --git a/apps/remix-ide/src/app/files/fileManager.ts b/apps/remix-ide/src/app/files/fileManager.ts index db42bc3335..c992cd5198 100644 --- a/apps/remix-ide/src/app/files/fileManager.ts +++ b/apps/remix-ide/src/app/files/fileManager.ts @@ -23,7 +23,7 @@ const profile = { version: packageJson.version, methods: ['closeAllFiles', 'closeFile', 'file', 'exists', 'open', 'writeFile', 'readFile', 'copyFile', 'copyDir', 'rename', 'mkdir', 'readdir', 'dirList', 'fileList', 'remove', 'getCurrentFile', 'getFile', 'getFolder', 'setFile', 'switchFile', 'refresh', - 'getProviderOf', 'getProviderByName', 'getPathFromUrl', 'getUrlFromPath', 'saveCurrentFile', 'setBatchFiles', 'isGitRepo'], + 'getProviderOf', 'getProviderByName', 'currentFileProvider', 'getPathFromUrl', 'getUrlFromPath', 'saveCurrentFile', 'setBatchFiles', 'isGitRepo'], kind: 'file-system' } const errorMsg = { diff --git a/apps/remix-ide/src/app/files/fileProvider.js b/apps/remix-ide/src/app/files/fileProvider.js index 9b7e63efdf..b40beec235 100644 --- a/apps/remix-ide/src/app/files/fileProvider.js +++ b/apps/remix-ide/src/app/files/fileProvider.js @@ -212,8 +212,19 @@ class FileProvider { * @param {string} path is the folder to be copied over * @param {Function} visitFile is a function called for each visited files * @param {Function} visitFolder is a function called for each visited folders + * @param {Function} customSystemStructureValue is a function called for customizing the json structure values + * @param {Function} customSystemStructureKey is a function called for customizing the json structure keys */ - async _copyFolderToJsonInternal (path, visitFile, visitFolder) { + async _copyFolderToJsonInternal (path, visitFile, visitFolder, customSystemStructureValue, customSystemStructureKey) { + customSystemStructureKey = customSystemStructureKey || function (path) { return path } + customSystemStructureValue = customSystemStructureValue || function (type, content) { + if (type === 'folder') { + return { children: content } + } else if (type === 'file') { + return { content } + } + return content + } visitFile = visitFile || function () { /* do nothing. */ } visitFolder = visitFolder || function () { /* do nothing. */ } @@ -225,15 +236,16 @@ class FileProvider { visitFolder({ path }) if (items.length !== 0) { for (const item of items) { - const file = {} + let file = {} const curPath = `${path}${path.endsWith('/') ? '' : '/'}${item}` if ((await window.remixFileSystem.stat(curPath)).isDirectory()) { - file.children = await this._copyFolderToJsonInternal(curPath, visitFile, visitFolder) + file = customSystemStructureValue('folder', await this._copyFolderToJsonInternal(curPath, visitFile, visitFolder, customSystemStructureValue, customSystemStructureKey)) } else { - file.content = await window.remixFileSystem.readFile(curPath, 'utf8') - visitFile({ path: curPath, content: file.content }) + const content = await window.remixFileSystem.readFile(curPath, 'utf8') + file = customSystemStructureValue('file', content, item) + visitFile({ folder: path, path: curPath, name: item, content }) } - json[curPath] = file + json[customSystemStructureKey(curPath)] = file } } } catch (e) { @@ -249,11 +261,22 @@ class FileProvider { * @param {string} path is the folder to be copied over * @param {Function} visitFile is a function called for each visited files * @param {Function} visitFolder is a function called for each visited folders + * @param {Function} customSystemStructureValue is a function called for customizing the json structure + * @param {Function} customSystemStructureKey is a function called for customizing the json structure keys */ - async copyFolderToJson (path, visitFile, visitFolder) { + async copyFolderToJson (path, visitFile, visitFolder, customSystemStructureValue, customSystemStructureKey) { + customSystemStructureKey = customSystemStructureKey || function (path) { return path } + customSystemStructureValue = customSystemStructureValue || function (type, content) { + if (type === 'folder') { + return { children: content } + } else if (type === 'file') { + return { content } + } + return content + } visitFile = visitFile || function () { /* do nothing. */ } visitFolder = visitFolder || function () { /* do nothing. */ } - return await this._copyFolderToJsonInternal(path, visitFile, visitFolder) + return await this._copyFolderToJsonInternal(path, visitFile, visitFolder, customSystemStructureValue, customSystemStructureKey) } async removeFile (path) { diff --git a/apps/remix-ide/src/app/files/workspaceFileProvider.js b/apps/remix-ide/src/app/files/workspaceFileProvider.js index 6193e7b486..e76fcb557c 100644 --- a/apps/remix-ide/src/app/files/workspaceFileProvider.js +++ b/apps/remix-ide/src/app/files/workspaceFileProvider.js @@ -53,7 +53,7 @@ class WorkspaceFileProvider extends FileProvider { }) } - async copyFolderToJson (directory, visitFile, visitFolder) { + async copyFolderToJson (directory, visitFile, visitFolder, customSystemStructureValue, customSystemStructureKey) { visitFile = visitFile || function () { /* do nothing. */ } visitFolder = visitFolder || function () { /* do nothing. */ } const regex = new RegExp(`.workspaces/${this.workspace}/`, 'g') @@ -61,7 +61,7 @@ class WorkspaceFileProvider extends FileProvider { visitFile({ path: path.replace(regex, ''), content }) }, ({ path }) => { visitFolder({ path: path.replace(regex, '') }) - }) + }, customSystemStructureValue, customSystemStructureKey) json = JSON.stringify(json).replace(regex, '') return JSON.parse(json) } diff --git a/apps/remix-ide/src/app/plugins/web-container/terminalCodesToHtml.tsx b/apps/remix-ide/src/app/plugins/web-container/terminalCodesToHtml.tsx new file mode 100644 index 0000000000..2774438a44 --- /dev/null +++ b/apps/remix-ide/src/app/plugins/web-container/terminalCodesToHtml.tsx @@ -0,0 +1,191 @@ +import Anser, { AnserJsonEntry } from "anser"; +import { escapeCarriageReturn } from "escape-carriage"; +import * as React from "react"; + +/** + * Converts ANSI strings into JSON output. + * @name ansiToJSON + * @function + * @param {String} input The input string. + * @param {boolean} use_classes If `true`, HTML classes will be appended + * to the HTML output. + * @return {Array} The parsed input. + */ +function ansiToJSON( + input: string, + use_classes: boolean = false +): AnserJsonEntry[] { + input = escapeCarriageReturn(fixBackspace(input)); + return Anser.ansiToJson(input, { + json: true, + remove_empty: true, + use_classes, + }); +} + +/** + * Create a class string. + * @name createClass + * @function + * @param {AnserJsonEntry} bundle + * @return {String} class name(s) + */ +function createClass(bundle: AnserJsonEntry): string | null { + let classNames: string = ""; + + if (bundle.bg) { + classNames += `${bundle.bg}-bg `; + } + if (bundle.fg) { + classNames += `${bundle.fg}-fg `; + } + if (bundle.decoration) { + classNames += `ansi-${bundle.decoration} `; + } + + if (classNames === "") { + return null; + } + + classNames = classNames.substring(0, classNames.length - 1); + return classNames; +} + +/** + * Create the style attribute. + * @name createStyle + * @function + * @param {AnserJsonEntry} bundle + * @return {Object} returns the style object + */ +function createStyle(bundle: AnserJsonEntry): React.CSSProperties { + const style: React.CSSProperties = {}; + if (bundle.bg) { + style.backgroundColor = `rgb(${bundle.bg})`; + } + if (bundle.fg) { + style.color = `rgb(${bundle.fg})`; + } + switch (bundle.decoration) { + case 'bold': + style.fontWeight = 'bold'; + break; + case 'dim': + style.opacity = '0.5'; + break; + case 'italic': + style.fontStyle = 'italic'; + break; + case 'hidden': + style.visibility = 'hidden'; + break; + case 'strikethrough': + style.textDecoration = 'line-through'; + break; + case 'underline': + style.textDecoration = 'underline'; + break; + case 'blink': + style.textDecoration = 'blink'; + break; + default: + break; + } + return style; +} + +/** + * Converts an Anser bundle into a React Node. + * @param linkify whether links should be converting into clickable anchor tags. + * @param useClasses should render the span with a class instead of style. + * @param bundle Anser output. + * @param key + */ + +function convertBundleIntoReact( + linkify: boolean, + useClasses: boolean, + bundle: AnserJsonEntry, + key: number +): JSX.Element { + const style = useClasses ? null : createStyle(bundle); + const className = useClasses ? createClass(bundle) : null; + + if (!linkify) { + return React.createElement( + "span", + { style, key, className }, + bundle.content + ); + } + + const content: React.ReactNode[] = []; + const linkRegex = /(\s|^)(https?:\/\/(?:www\.|(?!www))[^\s.]+\.[^\s]{2,}|www\.[^\s]+\.[^\s]{2,})/g; + + let index = 0; + let match: RegExpExecArray | null; + while ((match = linkRegex.exec(bundle.content)) !== null) { + const [, pre, url] = match; + + const startIndex = match.index + pre.length; + if (startIndex > index) { + content.push(bundle.content.substring(index, startIndex)); + } + + // Make sure the href we generate from the link is fully qualified. We assume http + // if it starts with a www because many sites don't support https + const href = url.startsWith("www.") ? `http://${url}` : url; + content.push( + React.createElement( + "a", + { + key: index, + href, + target: "_blank", + }, + `${url}` + ) + ); + + index = linkRegex.lastIndex; + } + + if (index < bundle.content.length) { + content.push(bundle.content.substring(index)); + } + + return React.createElement("span", { style, key, className }, content); +} + +declare interface Props { + children?: string; + linkify?: boolean; + className?: string; + useClasses?: boolean; +} + +export default function Ansi(props: Props): JSX.Element { + const { className, useClasses, children, linkify } = props; + return React.createElement( + "code", + { className }, + ansiToJSON(children ?? "", useClasses ?? false).map( + convertBundleIntoReact.bind(null, linkify ?? false, useClasses ?? false) + ) + ); +} + +// This is copied from the Jupyter Classic source code +// notebook/static/base/js/utils.js to handle \b in a way +// that is **compatible with Jupyter classic**. One can +// argue that this behavior is questionable: +// https://stackoverflow.com/questions/55440152/multiple-b-doesnt-work-as-expected-in-jupyter# +function fixBackspace(txt: string) { + let tmp = txt; + do { + txt = tmp; + // Cancel out anything-but-newline followed by backspace + tmp = txt.replace(/[^\n]\x08/gm, ""); + } while (tmp.length < txt.length); + return txt; +} \ No newline at end of file diff --git a/apps/remix-ide/src/app/plugins/web-container/web-container.ts b/apps/remix-ide/src/app/plugins/web-container/web-container.ts new file mode 100644 index 0000000000..1396ac8bd5 --- /dev/null +++ b/apps/remix-ide/src/app/plugins/web-container/web-container.ts @@ -0,0 +1,93 @@ +import { Plugin } from '@remixproject/engine' +import { WebContainer } from '@webcontainer/api' +import * as ts from "typescript"; +import Ansi from "./terminalCodesToHtml" +import { logBuilder } from "@remix-ui/helper" + +const profile = { + name: 'web-container-plugin', + displayName: 'WebContainerPlugin', + description: 'WebContainerPlugin', + methods: ['execute'], + events: [] +} + +export class WebContainerPlugin extends Plugin { + webcontainerInstance: WebContainer + constructor () { + super(profile) + WebContainer.boot().then((webcontainerInstance: WebContainer) => { + this.webcontainerInstance = webcontainerInstance + }).catch((error) => { + console.error(error) + }) + } + + async execute (script: string, filePath: string) { + const fileProvider = await this.call('fileManager', 'currentFileProvider') + if (!fileProvider.copyFolderToJson) throw new Error('provider does not support copyFolderToJson') + const files = await fileProvider.copyFolderToJson('/', null, null, (type, content, item) => { + if (type === 'folder') { + return { directory: content } + } else if (type === 'file') { + if (item.endsWith('.ts')) { + const output: ts.TranspileOutput = ts.transpileModule(content, { + // moduleName: filePath, + compilerOptions: { + target: ts.ScriptTarget.ES2015, + module: ts.ModuleKind.CommonJS, + esModuleInterop: true, + }}) + content = output.outputText + } + return { file: { contents: content } } + } + return content + }, (path) => { + if (!path) return path + path = path.split('/') + return stripOutExtension(path[path.length - 1]) + }) + console.log(files) + this.webcontainerInstance.mount(files) + await this.installDependencies() + const fileName = stripOutExtension(filePath) + let contentToRun = await this.webcontainerInstance.fs.readFile(fileName, 'utf8') + await this.webcontainerInstance.fs.writeFile(fileName, this.injectRemix(contentToRun), 'utf8') + const run = await this.webcontainerInstance.spawn('node', [stripOutExtension(filePath)]) + const self = this + run.output.pipeTo(new WritableStream({ + write(data) { + self.call('terminal', 'logHtml', Ansi({children: data})) + } + })); + } + + async installDependencies() { + // Install dependencies + const installProcess = await this.webcontainerInstance.spawn('npm', ['install']); + const self = this + installProcess.output.pipeTo(new WritableStream({ + write(data) { + self.call('terminal', 'logHtml', Ansi({children: data}) ) + } + })) + // Wait for install command to exit + return installProcess.exit; + } + + injectRemix (contentToRun) { + return ` + global.remix = { + test: () => { + console.log('iii') + } + };${contentToRun}` + } +} + +const stripOutExtension = (path) => { + if (!path.endsWith('.js') && !path.endsWith('.ts')) return path + if (!path) return path + return path.replace('.js', '').replace('.ts', '') +} diff --git a/apps/remix-ide/src/assets/js/loader.js b/apps/remix-ide/src/assets/js/loader.js index 0ed431eec3..a8c5692052 100644 --- a/apps/remix-ide/src/assets/js/loader.js +++ b/apps/remix-ide/src/assets/js/loader.js @@ -6,10 +6,10 @@ const domains = { } const domainsSecondaryTracker = { - 'remix-alpha.ethereum.org': 12, - 'remix-beta.ethereum.org': 10, - 'remix.ethereum.org': 8, - '6fd22d6fe5549ad4c4d8fd3ca0b7816b.mod': 20 // remix desktop + 'remix-alpha.ethereum.org': 27, + 'remix-beta.ethereum.org': 25, + 'remix.ethereum.org': 23, + '6fd22d6fe5549ad4c4d8fd3ca0b7816b.mod': 35 // remix desktop } if (domains[window.location.hostname]) { diff --git a/apps/remix-ide/webpack.config.js b/apps/remix-ide/webpack.config.js index a6aa923939..ba1773f498 100644 --- a/apps/remix-ide/webpack.config.js +++ b/apps/remix-ide/webpack.config.js @@ -5,6 +5,7 @@ const CopyPlugin = require("copy-webpack-plugin") const version = require('../../package.json').version const fs = require('fs') const TerserPlugin = require("terser-webpack-plugin") +const webpackMerge = require('webpack-merge'); const CssMinimizerPlugin = require("css-minimizer-webpack-plugin") const axios = require('axios') @@ -128,6 +129,13 @@ module.exports = composePlugins(withNx(), withReact(), (config) => { ignored: /node_modules/ } + webpackMerge.merge(config, { devServer: { + headers: { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin' + } + }}) + return config; }); diff --git a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx index 1c407149b7..fc202405e2 100644 --- a/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx +++ b/libs/remix-ui/tabs/src/lib/remix-ui-tabs.tsx @@ -165,7 +165,7 @@ export const TabsUI = (props: TabsUIProps) => { const path = active().substr(active().indexOf('/') + 1, active().length) const content = await props.plugin.call('fileManager', "readFile", path) if (tabsState.currentExt === 'js' || tabsState.currentExt === 'ts') { - await props.plugin.call('scriptRunner', 'execute', content, path) + await props.plugin.call('web-container-plugin', 'execute', content, path) _paq.push(['trackEvent', 'editor', 'clickRunFromEditor', tabsState.currentExt]) } else if (tabsState.currentExt === 'sol' || tabsState.currentExt === 'yul') { await props.plugin.call('solidity', 'compile', path) diff --git a/package.json b/package.json index c3527c0410..ef5cfb03b6 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,8 @@ "@web3modal/ethereum": "^2.2.2", "@web3modal/react": "^2.2.2", "@web3modal/standalone": "^2.2.2", + "@webcontainer/api": "^1.1.4", + "anser": "^2.1.1", "ansi-gray": "^0.1.1", "async": "^2.6.2", "axios": "1.1.2", @@ -156,6 +158,7 @@ "core-js": "^3.6.5", "deep-equal": "^1.0.1", "document-register-element": "1.13.1", + "escape-carriage": "^1.3.1", "eslint-config-prettier": "^8.5.0", "ethers": "^5", "ethjs-util": "^0.1.6", diff --git a/yarn.lock b/yarn.lock index c776210060..2202d32e11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6942,6 +6942,11 @@ "@webassemblyjs/ast" "1.11.1" "@xtuc/long" "4.2.2" +"@webcontainer/api@^1.1.4": + version "1.1.4" + resolved "https://registry.yarnpkg.com/@webcontainer/api/-/api-1.1.4.tgz#9447d40495e8055747fb02e9f7ccc093c3cce9ae" + integrity sha512-JjGQl25+QzuHOKLKr5QX0dAvqOFLw06YY47eWKMwkxz1otfYPtuhTRRjascqMLxGbLoRF6i1idV+vlInp5gwvA== + "@webpack-cli/configtest@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@webpack-cli/configtest/-/configtest-1.2.0.tgz#7b20ce1c12533912c3b217ea68262365fa29a6f5" @@ -7265,6 +7270,11 @@ align-text@^0.1.1, align-text@^0.1.3: longest "^1.0.1" repeat-string "^1.5.2" +anser@^2.1.1: + version "2.1.1" + resolved "https://registry.yarnpkg.com/anser/-/anser-2.1.1.tgz#8afae28d345424c82de89cc0e4d1348eb0c5af7c" + integrity sha512-nqLm4HxOTpeLOxcmB3QWmV5TcDFhW9y/fyQ+hivtDFcK4OQ+pQ5fzPnXHM1Mfcm0VkLtvVi1TCPr++Qy0Q/3EQ== + ansi-align@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/ansi-align/-/ansi-align-1.1.0.tgz#2f0c1658829739add5ebb15e6b0c6e3423f016ba" @@ -12417,6 +12427,11 @@ escalade@^3.1.1: resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== +escape-carriage@^1.3.1: + version "1.3.1" + resolved "https://registry.yarnpkg.com/escape-carriage/-/escape-carriage-1.3.1.tgz#842658e5422497b1232585e517dc813fc6a86170" + integrity sha512-GwBr6yViW3ttx1kb7/Oh+gKQ1/TrhYwxKqVmg5gS+BK+Qe2KrOa/Vh7w3HPBvgGf0LfcDGoY9I6NHKoA5Hozhw== + escape-html@~1.0.3: version "1.0.3" resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988"