From 365ab60fedc07eda86d01334f5f64dae8295025c Mon Sep 17 00:00:00 2001 From: Nicolas Gotchac Date: Thu, 15 Dec 2016 19:06:05 +0100 Subject: [PATCH] Use ServiceWorker for Contract compilation --- js/package.json | 1 + js/src/redux/providers/compilerActions.js | 57 ++++- js/src/redux/providers/compilerReducer.js | 9 +- js/src/serviceWorker.js | 185 +++++++++++++++ js/src/views/WriteContract/writeContract.css | 10 +- js/src/views/WriteContract/writeContract.js | 35 ++- .../views/WriteContract/writeContractStore.js | 219 +++++++++++------- js/webpack/app.js | 35 +-- 8 files changed, 442 insertions(+), 109 deletions(-) create mode 100644 js/src/serviceWorker.js diff --git a/js/package.json b/js/package.json index 615562adc..c43561799 100644 --- a/js/package.json +++ b/js/package.json @@ -117,6 +117,7 @@ "react-hot-loader": "3.0.0-beta.6", "react-intl-aggregate-webpack-plugin": "0.0.1", "rucksack-css": "0.9.1", + "serviceworker-webpack-plugin": "0.1.7", "sinon": "1.17.6", "sinon-as-promised": "4.0.2", "sinon-chai": "2.8.0", diff --git a/js/src/redux/providers/compilerActions.js b/js/src/redux/providers/compilerActions.js index c3b3a9bdd..b679830d2 100644 --- a/js/src/redux/providers/compilerActions.js +++ b/js/src/redux/providers/compilerActions.js @@ -14,7 +14,45 @@ // You should have received a copy of the GNU General Public License // along with Parity. If not, see . -import CompilerWorker from 'worker-loader!./compilerWorker.js'; +import PromiseWorker from 'promise-worker'; +import runtime from 'serviceworker-webpack-plugin/lib/runtime'; + +let workerRegistration; + +// Setup the Service Worker +if ('serviceWorker' in navigator) { + workerRegistration = runtime + .register() + .then(() => { + console.log('registering service worker'); + + if (navigator.serviceWorker.controller) { + // already active and controlling this page + return navigator.serviceWorker; + } + // wait for a new service worker to control this page + return new Promise((resolve, reject) => { + try { + const onControllerChange = () => { + navigator.serviceWorker.removeEventListener('controllerchange', onControllerChange); + resolve(navigator.serviceWorker); + }; + + navigator.serviceWorker.addEventListener('controllerchange', onControllerChange); + } catch (error) { + reject(error); + } + }); + }) + .then((_worker) => { + const worker = new PromiseWorker(_worker); + + console.log('registered service worker'); + return worker; + }); +} else { + workerRegistration = Promise.reject('Service Worker is not available in your browser.'); +} export function setWorker (worker) { return { @@ -23,6 +61,13 @@ export function setWorker (worker) { }; } +export function setError (error) { + return { + type: 'setError', + error + }; +} + export function setupWorker () { return (dispatch, getState) => { const state = getState(); @@ -31,7 +76,13 @@ export function setupWorker () { return; } - const worker = new CompilerWorker(); - dispatch(setWorker(worker)); + workerRegistration + .then((worker) => { + dispatch(setWorker(worker)); + }) + .catch((error) => { + console.error('sw', error); + dispatch(setError(error)); + }); }; } diff --git a/js/src/redux/providers/compilerReducer.js b/js/src/redux/providers/compilerReducer.js index 7163ac7a5..7470f0751 100644 --- a/js/src/redux/providers/compilerReducer.js +++ b/js/src/redux/providers/compilerReducer.js @@ -17,13 +17,18 @@ import { handleActions } from 'redux-actions'; const initialState = { - worker: null + worker: null, + error: null }; export default handleActions({ setWorker (state, action) { const { worker } = action; - return Object.assign({}, state, { worker }); + }, + + setError (state, action) { + const { error } = action; + return Object.assign({}, state, { error }); } }, initialState); diff --git a/js/src/serviceWorker.js b/js/src/serviceWorker.js new file mode 100644 index 000000000..0488c2b61 --- /dev/null +++ b/js/src/serviceWorker.js @@ -0,0 +1,185 @@ +// Copyright 2015, 2016 Parity Technologies (UK) Ltd. +// This file is part of Parity. + +// Parity is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. + +// Parity is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. + +// You should have received a copy of the GNU General Public License +// along with Parity. If not, see . + +import solc from 'solc/browser-wrapper'; +import { isWebUri } from 'valid-url'; +import registerPromiseWorker from 'promise-worker/register'; + +const CACHE_NAME = 'parity-cache-v1'; + +registerPromiseWorker((msg) => { + return handleMessage(msg); +}); + +self.addEventListener('install', (event) => { + event.waitUntil(self.skipWaiting()); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.solcVersions = {}; +self.files = {}; + +function handleMessage (message) { + switch (message.action) { + case 'compile': + return compile(message.data); + + case 'load': + return load(message.data); + + case 'setFiles': + return setFiles(message.data); + + default: + console.warn(`unknown action "${message.action}"`); + return null; + } +} + +function setFiles (files) { + const prevFiles = self.files; + const nextFiles = files.reduce((obj, file) => { + obj[file.name] = file.sourcecode; + return obj; + }, {}); + + self.files = { + ...prevFiles, + ...nextFiles + }; + + return 'ok'; +} + +// @todo re-implement find imports (with ASYNC fetch) +// function findImports (path) { +// if (self.files[path]) { +// if (self.files[path].error) { +// return Promise.reject(self.files[path].error); +// } + +// return Promise.resolve(self.files[path]); +// } + +// if (isWebUri(path)) { +// console.log('[sw] fetching', path); + +// return fetch(path) +// .then((r) => r.text()) +// .then((c) => { +// console.log('[sw]', 'got content at ' + path); +// self.files[path] = c; +// return c; +// }) +// .catch((e) => { +// console.error('[sw]', 'fetching', path, e); +// self.files[path] = { error: e }; +// throw e; +// }); +// } + +// console.log(`[sw] path ${path} not found...`); +// return Promise.reject('File not found'); +// } + +function compile (data, optimized = 1) { + const { sourcecode, build } = data; + + return fetchSolidity(build) + .then((compiler) => { + const start = Date.now(); + console.log('[sw] compiling...'); + + const input = { + '': sourcecode + }; + + const compiled = compiler.compile({ sources: input }, optimized); + + const time = Math.round((Date.now() - start) / 100) / 10; + console.log(`[sw] done compiling in ${time}s`); + + compiled.version = build.longVersion; + + return compiled; + }); +} + +function load (build) { + return fetchSolidity(build) + .then(() => 'ok'); +} + +function fetchSolc (build) { + const { path, longVersion } = build; + const URL = `https://raw.githubusercontent.com/ethereum/solc-bin/gh-pages/bin/${path}`; + + return caches + .match(URL) + .then((response) => { + if (response) { + return response; + } + + console.log(`[sw] fetching solc-bin ${longVersion} at ${URL}`); + + return fetch(URL) + .then((response) => { + if (!response || response.status !== 200 || response.type !== 'basic') { + return response; + } + + const responseToCache = response.clone(); + + caches.open(CACHE_NAME) + .then((cache) => { + cache.put(URL, responseToCache); + }); + + return response; + }); + }); +} + +function fetchSolidity (build) { + const { path, longVersion } = build; + + if (self.solcVersions[path]) { + return Promise.resolve(self.solcVersions[path]); + } + + return fetchSolc(build) + .then((r) => r.text()) + .then((code) => { + const solcCode = code.replace(/^var Module;/, 'var Module=self.__solcModule;'); + self.__solcModule = {}; + + console.log(`[sw] evaluating ${longVersion}`); + + // eslint-disable-next-line no-eval + eval(solcCode); + + console.log(`[sw] done evaluating ${longVersion}`); + + const compiler = solc(self.__solcModule); + self.solcVersions[path] = compiler; + + return compiler; + }); +} diff --git a/js/src/views/WriteContract/writeContract.css b/js/src/views/WriteContract/writeContract.css index 2502c4060..feed8616e 100644 --- a/js/src/views/WriteContract/writeContract.css +++ b/js/src/views/WriteContract/writeContract.css @@ -45,6 +45,14 @@ } } +.error { + background-color: rgba(200, 0, 0, 0.25); + padding: 1em 0.5em; + margin-top: -0.5em; + font-family: monospace; + font-size: 0.9em; +} + .mainEditor { &:global(.ace-solarized-dark) { background-color: rgba(0, 0, 0, 0.5); @@ -87,13 +95,13 @@ display: flex; flex-direction: column; margin-right: 0.5em; - .panel { background-color: rgba(0, 0, 0, 0.5); padding: 1em; flex: 1; display: flex; flex-direction: column; + box-sizing: border-box; } .compilation { diff --git a/js/src/views/WriteContract/writeContract.js b/js/src/views/WriteContract/writeContract.js index 31c4dd244..f6a03df9e 100644 --- a/js/src/views/WriteContract/writeContract.js +++ b/js/src/views/WriteContract/writeContract.js @@ -42,10 +42,11 @@ class WriteContract extends Component { static propTypes = { accounts: PropTypes.object.isRequired, setupWorker: PropTypes.func.isRequired, - worker: PropTypes.object + worker: PropTypes.object, + workerError: PropTypes.any }; - store = new WriteContractStore(); + store = WriteContractStore.get(); state = { resizing: false, @@ -57,22 +58,31 @@ class WriteContract extends Component { setupWorker(); if (worker) { - this.store.setCompiler(worker); + this.store.setWorker(worker); } } componentDidMount () { this.store.setEditor(this.refs.editor); + if (this.props.workerError) { + this.store.setWorkerError(this.props.workerError); + } + // Wait for editor to be loaded window.setTimeout(() => { this.store.resizeEditor(); }, 2000); } + // Set the worker if not set before (eg. first page loading) componentWillReceiveProps (nextProps) { if (!this.props.worker && nextProps.worker) { - this.store.setCompiler(nextProps.worker); + this.store.setWorker(nextProps.worker); + } + + if (this.props.workerError !== nextProps.workerError) { + this.store.setWorkerError(nextProps.workerError); } } @@ -217,7 +227,18 @@ class WriteContract extends Component { } renderParameters () { - const { compiling, contract, selectedBuild, loading } = this.store; + const { compiling, contract, selectedBuild, loading, workerError } = this.store; + + if (workerError) { + return ( +
+
+

Unfortuantely, an error occurred...

+
{ workerError }
+
+
+ ); + } if (selectedBuild < 0) { return ( @@ -485,8 +506,8 @@ class WriteContract extends Component { function mapStateToProps (state) { const { accounts } = state.personal; - const { worker } = state.compiler; - return { accounts, worker }; + const { worker, error } = state.compiler; + return { accounts, worker, workerError: error }; } function mapDispatchToProps (dispatch) { diff --git a/js/src/views/WriteContract/writeContractStore.js b/js/src/views/WriteContract/writeContractStore.js index dd1985466..9f81a63e5 100644 --- a/js/src/views/WriteContract/writeContractStore.js +++ b/js/src/views/WriteContract/writeContractStore.js @@ -18,6 +18,8 @@ import { action, observable } from 'mobx'; import store from 'store'; import { debounce } from 'lodash'; +import { sha3 } from '~/api/util/sha3'; + const WRITE_CONTRACT_STORE_KEY = '_parity::writeContractStore'; const SNIPPETS = { @@ -43,6 +45,8 @@ const SNIPPETS = { } }; +let instance = null; + export default class WriteContractStore { @observable sourcecode = ''; @@ -68,45 +72,47 @@ export default class WriteContractStore { @observable savedContracts = {}; @observable selectedContract = {}; + @observable workerError = null; + + lastCompilation = {}; snippets = SNIPPETS; + worker = null; constructor () { - this.reloadContracts(); - this.fetchSolidityVersions(); - this.debouncedCompile = debounce(this.handleCompile, 1000); } + static get () { + if (!instance) { + instance = new WriteContractStore(); + } + + return instance; + } + + @action setWorkerError (error) { + this.workerError = error; + } + @action setEditor (editor) { this.editor = editor; } - @action setCompiler (compiler) { - this.compiler = compiler; + @action setWorker (worker) { + this.worker = worker; - this.compiler.onmessage = (event) => { - const message = JSON.parse(event.data); - - switch (message.event) { - case 'compiled': - this.parseCompiled(message.data); - break; - case 'loading': - this.parseLoading(message.data); - break; - case 'try-again': - this.handleCompile(); - break; - } - }; + this + .fetchSolidityVersions() + .then(() => this.reloadContracts()); } fetchSolidityVersions () { - fetch('https://raw.githubusercontent.com/ethereum/solc-bin/gh-pages/bin/list.json') + return fetch('https://raw.githubusercontent.com/ethereum/solc-bin/gh-pages/bin/list.json') .then((r) => r.json()) .then((data) => { const { builds, releases, latestRelease } = data; let latestIndex = -1; + let promise = Promise.resolve(); this.builds = builds.reverse().map((build, index) => { if (releases[build.version] === build.path) { @@ -114,7 +120,7 @@ export default class WriteContractStore { if (build.version === latestRelease) { build.latest = true; - this.loadSolidityVersion(build); + promise = promise.then(() => this.loadSolidityVersion(build)); latestIndex = index; } } @@ -123,29 +129,40 @@ export default class WriteContractStore { }); this.selectedBuild = latestIndex; + return promise; }); } - @action closeWorker = () => { - this.compiler.postMessage(JSON.stringify({ - action: 'close' - })); - } - @action handleImport = (sourcecode) => { this.reloadContracts(-1, sourcecode); } @action handleSelectBuild = (_, index, value) => { this.selectedBuild = value; - this.loadSolidityVersion(this.builds[value]); + return this.loadSolidityVersion(this.builds[value]); } @action loadSolidityVersion = (build) => { - this.compiler.postMessage(JSON.stringify({ - action: 'load', - data: build - })); + if (!this.worker) { + return; + } + + return this.worker + .postMessage({ + action: 'load', + data: build + }) + .then((result) => { + if (result !== 'ok') { + this.setWorkerError(result); + } + }) + .catch((error) => { + this.setWorkerError(error); + }) + .then(() => { + this.loading = false; + }); } @action handleOpenDeployModal = () => { @@ -177,23 +194,94 @@ export default class WriteContractStore { this.contract = this.contracts[Object.keys(this.contracts)[value]]; } - @action handleCompile = () => { + @action handleCompile = (loadFiles = false) => { this.compiled = false; this.compiling = true; const build = this.builds[this.selectedBuild]; + const version = build.longVersion; + const sourcecode = this.sourcecode.replace(/\n+/g, '\n').replace(/\s(\s+)/g, ' '); + const hash = sha3(JSON.stringify({ version, sourcecode })); - if (this.compiler && typeof this.compiler.postMessage === 'function') { - this.sendFilesToWorker(); + let promise = Promise.resolve(null); - this.compiler.postMessage(JSON.stringify({ - action: 'compile', - data: { - sourcecode: this.sourcecode, - build: build - } - })); + if (hash === this.lastCompilation.hash) { + promise = new Promise((resolve) => { + window.setTimeout(() => { + resolve(this.lastCompilation); + }, 500); + }); + } else if (this.worker) { + promise = loadFiles + ? this.sendFilesToWorker() + : Promise.resolve(); + + promise = promise + .then(() => { + return this.worker.postMessage({ + action: 'compile', + data: { + sourcecode: sourcecode, + build: build + } + }); + }) + .then((data) => { + const result = this.parseCompiled(data); + + this.lastCompilation = { + result: result, + date: new Date(), + version: data.version, + hash + }; + + return this.lastCompilation; + }) + .catch((error) => { + this.setWorkerError(error); + }); } + + return promise.then((data = {}) => { + const { + contract, contractIndex, + annotations, contracts, errors + } = data.result; + + this.contract = contract; + this.contractIndex = contractIndex; + + this.annotations = annotations; + this.contracts = contracts; + this.errors = errors; + + this.compiled = true; + this.compiling = false; + }); + } + + @action parseCompiled = (data) => { + const { contracts } = data; + + const { errors = [] } = data; + const errorAnnotations = this.parseErrors(errors); + const formalAnnotations = this.parseErrors(data.formal && data.formal.errors, true); + + const annotations = [].concat( + errorAnnotations, + formalAnnotations + ); + + const contractKeys = Object.keys(contracts || {}); + + const contract = contractKeys.length ? contracts[contractKeys[0]] : null; + const contractIndex = contractKeys.length ? 0 : -1; + + return { + contract, contractIndex, + contracts, errors, annotations + }; } parseErrors = (data, formal = false) => { @@ -220,43 +308,6 @@ export default class WriteContractStore { }); } - @action parseCompiled = (data) => { - const { contracts } = data; - - const { errors = [] } = data; - const errorAnnotations = this.parseErrors(errors); - const formalAnnotations = this.parseErrors(data.formal && data.formal.errors, true); - - const annotations = [].concat( - errorAnnotations, - formalAnnotations - ); - - if (annotations.findIndex((a) => /__parity_tryAgain/.test(a.text)) > -1) { - return; - } - - const contractKeys = Object.keys(contracts || {}); - - this.contract = contractKeys.length ? contracts[contractKeys[0]] : null; - this.contractIndex = contractKeys.length ? 0 : -1; - - this.contracts = contracts; - this.errors = errors; - this.annotations = annotations; - - this.compiled = true; - this.compiling = false; - } - - @action parseLoading = (isLoading) => { - this.loading = isLoading; - - if (!isLoading) { - this.handleCompile(); - } - } - @action handleEditSourcecode = (value, compile = false) => { this.sourcecode = value; @@ -327,8 +378,10 @@ export default class WriteContractStore { current: this.sourcecode }); - this.handleCompile(); this.resizeEditor(); + + // Send the new files to the Worker and compile + return this.handleCompile(true); } @action handleLoadContract = (contract) => { @@ -369,10 +422,10 @@ export default class WriteContractStore { Object.values(this.savedContracts) ); - this.compiler.postMessage(JSON.stringify({ + return this.worker.postMessage({ action: 'setFiles', data: files - })); + }); } } diff --git a/js/webpack/app.js b/js/webpack/app.js index cf38ec99c..a2ff20ced 100644 --- a/js/webpack/app.js +++ b/js/webpack/app.js @@ -22,6 +22,7 @@ const WebpackErrorNotificationPlugin = require('webpack-error-notification'); const CopyWebpackPlugin = require('copy-webpack-plugin'); const HtmlWebpackPlugin = require('html-webpack-plugin'); const ExtractTextPlugin = require('extract-text-webpack-plugin'); +const ServiceWorkerWebpackPlugin = require('serviceworker-webpack-plugin'); const Shared = require('./shared'); const DAPPS = require('../src/dapps'); @@ -50,7 +51,7 @@ module.exports = { rules: [ { test: /\.js$/, - exclude: /node_modules/, + exclude: /(node_modules)/, // use: [ 'happypack/loader?id=js' ] use: isProd ? ['babel-loader'] : [ 'babel-loader?cacheDirectory=true' @@ -136,7 +137,18 @@ module.exports = { }, plugins: (function () { - const plugins = Shared.getPlugins().concat([ + const DappsHTMLInjection = DAPPS.map((dapp) => { + return new HtmlWebpackPlugin({ + title: dapp.title, + filename: dapp.name + '.html', + template: './dapps/index.ejs', + favicon: FAVICON, + secure: dapp.secure, + chunks: [ isProd ? null : 'commons', dapp.name ] + }); + }); + + const plugins = Shared.getPlugins().concat( new CopyWebpackPlugin([{ from: './error_pages.css', to: 'styles.css' }], {}), new WebpackErrorNotificationPlugin(), @@ -151,17 +163,14 @@ module.exports = { template: './index.ejs', favicon: FAVICON, chunks: [ isProd ? null : 'commons', 'index' ] - }) - ], DAPPS.map((dapp) => { - return new HtmlWebpackPlugin({ - title: dapp.title, - filename: dapp.name + '.html', - template: './dapps/index.ejs', - favicon: FAVICON, - secure: dapp.secure, - chunks: [ isProd ? null : 'commons', dapp.name ] - }); - })); + }), + + new ServiceWorkerWebpackPlugin({ + entry: path.join(__dirname, '../src/serviceWorker.js'), + }), + + DappsHTMLInjection + ); if (!isProd) { const DEST_I18N = path.join(__dirname, '..', DEST, 'i18n');