From c55bf21eaa17034240678ea98d54bc66c7c6da1d Mon Sep 17 00:00:00 2001 From: Empowerful <45366397+Empowerful@users.noreply.github.com> Date: Tue, 12 Mar 2019 10:25:14 -0400 Subject: [PATCH] POC: Configure two Web Wallet applications (Root Document & Main Process) to coexist. The Root Document handles wallet synchronization for both applications. --- .../package.json | 1 + .../src/data/logs/reducers.js | 1 + .../src/middleware/actionTypes.js | 1 + .../src/middleware/forwardActions.js | 11 + .../src/middleware/index.js | 2 + .../src/store/index.js | 288 +++++++----- .../webpack.config.dev.js | 16 +- .../src/network/api/http.js | 3 +- .../src/network/api/index.js | 3 +- .../src/network/walletApi.js | 13 +- .../src/redux/wallet/actionTypes.js | 1 + .../src/redux/wallet/actions.js | 4 + .../src/redux/wallet/sagas.js | 9 + .../src/redux/walletSync/middleware.js | 107 ++--- .../src/redux/walletSync/middleware.spec.js | 14 +- main-process/packages/web-microkernel | 1 + main-process/yarn.lock | 11 +- .../package.json | 1 + .../src/data/logs/reducers.js | 1 + .../src/index.html | 82 +++- .../src/middleware/actionTypes.js | 1 + .../src/middleware/forwardActions.js | 11 + .../src/middleware/index.js | 2 + .../src/store/index.js | 270 +++++++----- .../webpack.config.dev.js | 3 +- .../__snapshots__/walletReducers.spec.js.snap | 86 ++++ .../src/redux/wallet/actionTypes.js | 1 + .../src/redux/wallet/actions.js | 5 + .../src/redux/wallet/reducers.js | 36 +- .../src/redux/wallet/walletReducers.spec.js | 17 + .../src/types/Serializer.js | 10 +- packages/web-microkernel/package.json | 9 + packages/web-microkernel/src/Multiplexed.js | 39 ++ .../web-microkernel/src/Multiplexed.test.js | 103 +++++ .../web-microkernel/src/RealmConnection.js | 409 ++++++++++++++++++ .../src/RealmConnection.test.js | 390 +++++++++++++++++ packages/web-microkernel/src/index.js | 4 + packages/web-microkernel/src/lodash-es | 1 + packages/web-microkernel/wallaby.js | 7 + packages/web-microkernel/yarn.lock | 8 + yarn.lock | 11 +- 41 files changed, 1666 insertions(+), 327 deletions(-) create mode 100644 main-process/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js create mode 100644 main-process/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js create mode 120000 main-process/packages/web-microkernel create mode 100644 packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js create mode 100644 packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js create mode 100644 packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap create mode 100644 packages/web-microkernel/package.json create mode 100644 packages/web-microkernel/src/Multiplexed.js create mode 100644 packages/web-microkernel/src/Multiplexed.test.js create mode 100644 packages/web-microkernel/src/RealmConnection.js create mode 100644 packages/web-microkernel/src/RealmConnection.test.js create mode 100644 packages/web-microkernel/src/index.js create mode 120000 packages/web-microkernel/src/lodash-es create mode 100644 packages/web-microkernel/wallaby.js create mode 100644 packages/web-microkernel/yarn.lock diff --git a/main-process/packages/blockchain-wallet-v4-frontend/package.json b/main-process/packages/blockchain-wallet-v4-frontend/package.json index 9aea4db5df6..69f745e29b2 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/package.json +++ b/main-process/packages/blockchain-wallet-v4-frontend/package.json @@ -88,6 +88,7 @@ "@ledgerhq/hw-app-btc": "4.30.0", "@ledgerhq/hw-app-str": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-transport-u2f": "4.31.0", + "@nodeguy/channel": "0.6.4", "awesome-phonenumber": "2.2.6", "base-64": "0.1.0", "bignumber.js": "8.0.1", diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js b/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js index c905bf90105..119fef2b1b3 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js @@ -18,6 +18,7 @@ const logger = (state = INITIAL_STATE, action) => { switch (type) { case AT.LOG_ERROR_MSG: { + console.error(payload) return insert(0, createLog('ERROR', payload), state) } case AT.LOG_INFO_MSG: { diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js b/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js new file mode 100644 index 00000000000..64a7efe5ecd --- /dev/null +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js @@ -0,0 +1 @@ +export const FORWARD = `FORWARD` diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js b/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js new file mode 100644 index 00000000000..2018d400603 --- /dev/null +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js @@ -0,0 +1,11 @@ +import { FORWARD } from './actionTypes' + +export default ({ forward, types }) => () => next => action => { + if (types.has(action.type)) { + forward(action) + } else if (action.type === FORWARD) { + forward(action.payload) + } + + return next(action) +} diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/index.js b/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/index.js index 07c8c525f27..52d564ffca7 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/index.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/middleware/index.js @@ -1,4 +1,5 @@ import autoDisconnection from './autoDisconnection' +import forwardActions from './forwardActions' import webSocketBch from './webSocketBch' import webSocketBtc from './webSocketBtc' import webSocketEth from './webSocketEth' @@ -7,6 +8,7 @@ import streamingXlm from './streamingXlm' export { autoDisconnection, + forwardActions, streamingXlm, webSocketBch, webSocketBtc, diff --git a/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js b/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js index 2efff0316cf..4267ce48a73 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/src/store/index.js @@ -1,11 +1,14 @@ +import { AUTHENTICATE, LOGOUT } from '../data/auth/actionTypes' +import Channel from '@nodeguy/channel' import { createStore, applyMiddleware, compose } from 'redux' import createSagaMiddleware from 'redux-saga' import { persistStore, persistCombineReducers } from 'redux-persist' +import { RealmConnection, Multiplexed } from '../../../web-microkernel/src' import storage from 'redux-persist/lib/storage' import getStoredStateMigrateV4 from 'redux-persist/lib/integration/getStoredStateMigrateV4' import { createHashHistory } from 'history' import { connectRouter, routerMiddleware } from 'connected-react-router' -import { head } from 'ramda' +import { clone, head, omit } from 'ramda' import Bitcoin from 'bitcoinjs-lib' import BitcoinCash from 'bitcoinforksjs-lib' @@ -20,6 +23,7 @@ import { serializer } from 'blockchain-wallet-v4/src/types' import { actions, rootSaga, rootReducer, selectors } from 'data' import { autoDisconnection, + forwardActions, streamingXlm, webSocketBch, webSocketBtc, @@ -29,6 +33,7 @@ import { const devToolsConfig = { maxAge: 1000, + name: `Main Process`, serialize: serializer, actionsBlacklist: [ // '@@redux-form/INITIALIZE', @@ -43,118 +48,193 @@ const devToolsConfig = { ] } -const configureStore = () => { +const configureStore = async () => { const history = createHashHistory() const sagaMiddleware = createSagaMiddleware() const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ ? window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__(devToolsConfig) : compose - const walletPath = 'wallet.payload' const kvStorePath = 'wallet.kvstore' const isAuthenticated = selectors.auth.isAuthenticated - return fetch('/Resources/wallet-options-v4.json') - .then(res => res.json()) - .then(options => { - const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' - // TODO: deprecate when wallet-options-v4 is updated on prod - const socketUrl = head(options.domains.webSocket.split('/inv')) - const horizonUrl = options.domains.horizon - const btcSocket = new Socket({ - options, - url: `${socketUrl}/inv` - }) - const bchSocket = new Socket({ - options, - url: `${socketUrl}/bch/inv` - }) - const ethSocket = new Socket({ - options, - url: `${socketUrl}/eth/inv` - }) - const ratesSocket = new ApiSocket({ - options, - url: `${socketUrl}/nabu-gateway/markets/quotes`, - maxReconnects: 3 - }) - const xlmStreamingService = new HorizonStreamingService({ - url: horizonUrl - }) - const getAuthCredentials = () => - selectors.modules.profile.getAuthCredentials(store.getState()) - const reauthenticate = () => - store.dispatch(actions.modules.profile.signIn()) - const networks = { - btc: Bitcoin.networks[options.platforms.web.btc.config.network], - bch: BitcoinCash.networks[options.platforms.web.btc.config.network], - bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], - eth: options.platforms.web.eth.config.network, - xlm: options.platforms.web.xlm.config.network - } - const api = createWalletApi({ - options, - apiKey, - getAuthCredentials, - reauthenticate, - networks - }) - const persistWhitelist = ['session', 'preferences', 'cache'] - - // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) - const store = createStore( - connectRouter(history)( - persistCombineReducers( - { - getStoredState: getStoredStateMigrateV4({ - whitelist: persistWhitelist - }), - key: 'root', - storage, - whitelist: persistWhitelist - }, - rootReducer - ) - ), - composeEnhancers( - applyMiddleware( - sagaMiddleware, - routerMiddleware(history), - coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), - webSocketBtc(btcSocket), - webSocketBch(bchSocket), - webSocketEth(ethSocket), - streamingXlm(xlmStreamingService, api), - webSocketRates(ratesSocket), - coreMiddleware.walletSync({ isAuthenticated, api, walletPath }), - autoDisconnection() - ) - ) + const options = await (await fetch( + '/Resources/wallet-options-v4.json' + )).json() + + const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' + // TODO: deprecate when wallet-options-v4 is updated on prod + const socketUrl = head(options.domains.webSocket.split('/inv')) + const horizonUrl = options.domains.horizon + const btcSocket = new Socket({ + options, + url: `${socketUrl}/inv` + }) + const bchSocket = new Socket({ + options, + url: `${socketUrl}/bch/inv` + }) + const ethSocket = new Socket({ + options, + url: `${socketUrl}/eth/inv` + }) + const ratesSocket = new ApiSocket({ + options, + url: `${socketUrl}/nabu-gateway/markets/quotes`, + maxReconnects: 3 + }) + const xlmStreamingService = new HorizonStreamingService({ + url: horizonUrl + }) + + // The store isn't available by the time we want to export its dispatch method + // so use a channel to hold pending actions. + const actionsChannel = Channel() + + const rootDocument = await RealmConnection({ + exports: { dispatch: actionsChannel.push }, + input: Multiplexed({ realm: window, tag: `realms` }), + output: Multiplexed({ realm: window.parent, tag: `realms` }), + outputOrigin: options.domains.rootDocument + }) + + rootDocument.addEventListener(`error`, event => { + const plainError = pick( + [`message`, `filename`, `lineno`, `colno`, `error`], + event + ) + + store.dispatch( + actions.logs.logErrorMessage(`store`, `realm connection`, plainError) + ) + }) + + const getAuthCredentials = () => + selectors.modules.profile.getAuthCredentials(store.getState()) + const reauthenticate = () => store.dispatch(actions.modules.profile.signIn()) + const networks = { + btc: Bitcoin.networks[options.platforms.web.btc.config.network], + bch: BitcoinCash.networks[options.platforms.web.btc.config.network], + bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], + eth: options.platforms.web.eth.config.network, + xlm: options.platforms.web.xlm.config.network + } + + const axiosAdapter = async config => { + try { + // `transformRequest`, `transformResponse`, `validateStatus` are all + // performed locally so there's no need to call them in the root document. + + const sanitizedConfig = omit( + [`adapter`, `transformRequest`, `transformResponse`, `validateStatus`], + config ) - const persistor = persistStore(store, null) - - sagaMiddleware.run(rootSaga, { - api, - bchSocket, - btcSocket, - ethSocket, - ratesSocket, - networks, - options - }) - - // expose globals here - window.createTestXlmAccounts = () => { - store.dispatch(actions.core.data.xlm.createTestAccounts()) - } - - store.dispatch(actions.goals.defineGoals()) - - return { - store, - history, - persistor - } - }) + + const immutableResponse = await rootDocument.imports.axios( + sanitizedConfig + ) + + // Return a shallow clone because Axios wants to mutate it. + return { ...immutableResponse } + } catch (immutableException) { + // Create a mutable deep clone of the exception because Axios wants to + // mutate it. + + const plainObject = { ...immutableException } + + const mutableException = Object.assign( + Error(immutableException.message), + clone(plainObject) + ) + + throw mutableException + } + } + + const api = createWalletApi({ + axiosAdapter, + options, + apiKey, + getAuthCredentials, + reauthenticate, + networks + }) + + const persistWhitelist = ['session', 'preferences', 'cache'] + + // Forward the following action types to the root document. + // + // AUTHENTICATE: Enable the root document to synchronize the wallet. + // LOGOUT: Tell the root document to reload itself when we do. + const forwardActionTypes = new Set([AUTHENTICATE, LOGOUT]) + + // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) + const store = createStore( + connectRouter(history)( + persistCombineReducers( + { + getStoredState: getStoredStateMigrateV4({ + whitelist: persistWhitelist + }), + key: 'root', + storage, + whitelist: persistWhitelist + }, + rootReducer + ) + ), + composeEnhancers( + applyMiddleware( + sagaMiddleware, + routerMiddleware(history), + coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), + webSocketBtc(btcSocket), + webSocketBch(bchSocket), + webSocketEth(ethSocket), + streamingXlm(xlmStreamingService, api), + webSocketRates(ratesSocket), + + coreMiddleware.walletSync({ + isAuthenticated, + rootDocumentDispatch: rootDocument.imports.dispatch + }), + + autoDisconnection(), + + forwardActions({ + forward: rootDocument.imports.dispatch, + types: forwardActionTypes + }) + ) + ) + ) + const persistor = persistStore(store, null) + + sagaMiddleware.run(rootSaga, { + api, + bchSocket, + btcSocket, + ethSocket, + ratesSocket, + networks, + options + }) + + // Now that we have a store, dispatch pending and future actions from the + // channel. + actionsChannel.forEach(store.dispatch) + + // expose globals here + window.createTestXlmAccounts = () => { + store.dispatch(actions.core.data.xlm.createTestAccounts()) + } + + store.dispatch(actions.goals.defineGoals()) + + return { + store, + history, + persistor + } } export default configureStore diff --git a/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js b/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js index ca6f7010fc1..1fa64945a99 100644 --- a/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js +++ b/main-process/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js @@ -17,13 +17,14 @@ const iSignThisDomain = let envConfig = {} let manifestCacheBust = new Date().getTime() +const port = 8082 let sslEnabled = process.env.DISABLE_SSL ? false : fs.existsSync(PATHS.sslConfig + '/key.pem') && fs.existsSync(PATHS.sslConfig + '/cert.pem') let localhostUrl = sslEnabled - ? 'https://localhost:8080' - : 'http://localhost:8080' + ? `https://localhost:${port}` + : `http://localhost:${port}` try { envConfig = require(PATHS.envConfig + `/${process.env.NODE_ENV}` + '.js') @@ -61,7 +62,7 @@ module.exports = { app: [ '@babel/polyfill', 'react-hot-loader/patch', - 'webpack-dev-server/client?http://localhost:8080', + `webpack-dev-server/client?http://localhost:${port}`, 'webpack/hot/only-dev-server', PATHS.src + '/index.js' ] @@ -192,7 +193,7 @@ module.exports = { key: sslEnabled ? fs.readFileSync(PATHS.sslConfig + '/key.pem', 'utf8') : '', - port: 8080, + port, hot: true, historyApiFallback: true, before(app) { @@ -200,6 +201,7 @@ module.exports = { // combine wallet options base with custom environment config mockWalletOptions.domains = { root: envConfig.ROOT_URL, + rootDocument: `http://localhost:8080`, api: envConfig.API_DOMAIN, webSocket: envConfig.WEB_SOCKET_URL, walletHelper: envConfig.WALLET_HELPER_DOMAIN, @@ -257,13 +259,13 @@ module.exports = { "style-src 'self' 'unsafe-inline'", `frame-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} ${ envConfig.ROOT_URL - } https://magic.veriff.me https://localhost:8080 http://localhost:8080`, + } https://magic.veriff.me https://localhost:${port} http://localhost:${port}`, `child-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} blob:`, [ 'connect-src', "'self'", - 'ws://localhost:8080', - 'wss://localhost:8080', + `ws://localhost:${port}`, + `wss://localhost:${port}`, 'wss://api.ledgerwallet.com', 'wss://ws.testnet.blockchain.info/inv', envConfig.WEB_SOCKET_URL, diff --git a/main-process/packages/blockchain-wallet-v4/src/network/api/http.js b/main-process/packages/blockchain-wallet-v4/src/network/api/http.js index 18238b40022..65ff8b28e37 100755 --- a/main-process/packages/blockchain-wallet-v4/src/network/api/http.js +++ b/main-process/packages/blockchain-wallet-v4/src/network/api/http.js @@ -5,7 +5,7 @@ import { prop, path, pathOr, merge } from 'ramda' axios.defaults.withCredentials = false axios.defaults.timeout = Infinity -export default ({ apiKey }) => { +export default ({ axiosAdapter, apiKey }) => { const encodeData = (data, contentType) => { const defaultData = { api_code: apiKey, @@ -39,6 +39,7 @@ export default ({ apiKey }) => { ...options }) => axios({ + adapter: axiosAdapter, url: `${url}${endPoint}`, method, data: encodeData(data, contentType), diff --git a/main-process/packages/blockchain-wallet-v4/src/network/api/index.js b/main-process/packages/blockchain-wallet-v4/src/network/api/index.js index 92401c06321..34edbeefc6f 100755 --- a/main-process/packages/blockchain-wallet-v4/src/network/api/index.js +++ b/main-process/packages/blockchain-wallet-v4/src/network/api/index.js @@ -20,13 +20,14 @@ import httpService from './http' import apiAuthorize from './apiAuthorize' export default ({ + axiosAdapter, options, apiKey, getAuthCredentials, reauthenticate, networks } = {}) => { - const http = httpService({ apiKey }) + const http = httpService({ axiosAdapter, apiKey }) const authorizedHttp = apiAuthorize(http, getAuthCredentials, reauthenticate) const apiUrl = options.domains.api const horizonUrl = options.domains.horizon diff --git a/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js b/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js index dd9b47fd49a..43e9cbdb657 100755 --- a/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js +++ b/main-process/packages/blockchain-wallet-v4/src/network/walletApi.js @@ -20,18 +20,9 @@ import { futurizeP } from 'futurize' import createApi from './api' import * as Coin from '../coinSelection/coin.js' -const createWalletApi = ( - { options, apiKey, getAuthCredentials, reauthenticate, networks } = {}, - returnType -) => { +const createWalletApi = (options, returnType) => { // //////////////////////////////////////////////////////////////// - const ApiPromise = createApi({ - options, - apiKey, - getAuthCredentials, - reauthenticate, - networks - }) + const ApiPromise = createApi(options) const eitherToTask = e => e.fold(Task.rejected, Task.of) const taskToPromise = t => new Promise((resolve, reject) => t.fork(reject, resolve)) diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js index 1e71c3b27bd..6211d6c7718 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js @@ -1,4 +1,5 @@ // setters +export const MERGE_WRAPPER = '@CORE.MERGE_WRAPPER' export const SET_WRAPPER = '@CORE.SET_WRAPPER' export const REFRESH_WRAPPER = '@CORE.REFRESH_WRAPPER' export const SET_MAIN_PASSWORD = '@CORE.SET_MAIN_PASSWORD' diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js index 624d77e3563..0d5dee6ba97 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/actions.js @@ -1,6 +1,10 @@ import * as T from './actionTypes' // setters +export const mergeWrapper = payload => ({ + type: T.MERGE_WRAPPER, + payload: payload +}) export const setWrapper = payload => ({ type: T.SET_WRAPPER, payload: payload diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js index efd085340c9..b3c3b63475e 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/wallet/sagas.js @@ -1,3 +1,4 @@ +import { FORWARD } from '../../../../blockchain-wallet-v4-frontend/src/middleware/actionTypes' import { call, put, select } from 'redux-saga/effects' import BIP39 from 'bip39' import Bitcoin from 'bitcoinjs-lib' @@ -120,6 +121,14 @@ export default ({ api, networks }) => { code ) yield put(A.wallet.setWrapper(wrapper)) + + // Temporary: Forward the wrapper to the root document to initialize + // sensitive fields (seed, etc). This will be removed in the future when + // the root document handles login, etc. + yield put({ + type: FORWARD, + payload: A.wallet.setWrapper(wrapper) + }) } const upgradeToHd = function*({ password }) { diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js index 6e24f37c6ee..2c49a3c0ac3 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.js @@ -1,9 +1,5 @@ -import { futurizeP } from 'futurize' -import Task from 'data.task' import { compose, - assoc, - join, curry, range, keysIn, @@ -15,7 +11,6 @@ import { } from 'ramda' import { networks } from 'bitcoinjs-lib' -import * as A from '../actions' import * as T from '../actionTypes' import { Wrapper, Wallet, HDAccount } from '../../types' import * as selectors from '../selectors' @@ -93,6 +88,22 @@ export const getWalletAddresses = async (state, api) => { return activeAddresses.concat(uniq(hdAddresses.concat(unusedAddresses))) } +export const shouldSync = ({ + actionType, + newAuthenticated, + newWallet, + oldAuthenticated, + oldWallet +}) => + actionType === T.walletSync.FORCE_SYNC || + (oldAuthenticated && + newAuthenticated && + actionType !== T.wallet.SET_PAYLOAD_CHECKSUM && + actionType !== T.wallet.REFRESH_WRAPPER && + // Easily know when to sync, because of ✨immutable✨ data + // the initial_state check could be done against full payload state + oldWallet !== newWallet) + /** * Wallet sync middleware * Calls sync on special conditions @@ -101,73 +112,29 @@ export const getWalletAddresses = async (state, api) => { */ const walletSync = ({ isAuthenticated, - api + rootDocumentDispatch } = {}) => store => next => action => { - const prevState = store.getState() - const prevWallet = selectors.wallet.getWrapper(prevState) - const wasAuth = isAuthenticated(prevState) + const oldState = store.getState() const result = next(action) - - const state = store.getState() - const nextWallet = selectors.wallet.getWrapper(state) - const syncPubKeys = selectors.wallet.shouldSyncPubKeys(state) - const isAuth = isAuthenticated(state) - const promiseToTask = futurizeP(Task) - - // Easily know when to sync, because of ✨immutable✨ data - // the initial_state check could be done against full payload state - - const handleChecksum = encrypted => { - const checksum = Wrapper.computeChecksum(encrypted) - compose( - store.dispatch, - A.wallet.setPayloadChecksum - )(checksum) - return encrypted - } - - const sync = async () => { - let encryptedWallet = Wrapper.toEncJSON(nextWallet) - if (syncPubKeys) { - /** - * To get notifications working you have to add list of lookahead addresses - * For each of the wallet's accounts - */ - try { - const addresses = await getWalletAddresses(state, api) - encryptedWallet = encryptedWallet.map( - assoc('active', join('|', addresses)) - ) - } catch (error) { - return store.dispatch(A.walletSync.syncError(error)) - } - } - return encryptedWallet - .map(handleChecksum) - .chain(promiseToTask(api.savePayload)) - .fork( - compose( - store.dispatch, - A.walletSync.syncError - ), - compose( - store.dispatch, - A.walletSync.syncSuccess - ) - ) - } - - switch (true) { - case action.type === T.walletSync.FORCE_SYNC: - case wasAuth && - isAuth && - action.type !== T.wallet.SET_PAYLOAD_CHECKSUM && - action.type !== T.wallet.REFRESH_WRAPPER && - prevWallet !== nextWallet: - sync() - break - default: - break + const newState = store.getState() + const newWallet = selectors.wallet.getWrapper(newState) + + if ( + shouldSync({ + actionType: action.type, + newAuthenticated: isAuthenticated(newState), + newWallet, + oldAuthenticated: isAuthenticated(oldState), + oldWallet: selectors.wallet.getWrapper(oldState) + }) + ) { + rootDocumentDispatch({ + type: T.wallet.MERGE_WRAPPER, + + // Convert the wallet to JavaScript types so it can cross the realm + // boundary. + payload: Wrapper.toJS(newWallet) + }) } return result diff --git a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js index ac3655dbde9..0852d2de82f 100755 --- a/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js +++ b/main-process/packages/blockchain-wallet-v4/src/redux/walletSync/middleware.spec.js @@ -1,7 +1,8 @@ import { map, range } from 'ramda' import { addressLookaheadCount, - getHDAccountAddressPromises + getHDAccountAddressPromises, + shouldSync } from './middleware' import { getReceiveAddress } from '../../types/HDAccount' import { getAccountXpub } from '../wallet/selectors' @@ -80,3 +81,14 @@ describe('getHDAccountAddressPromises', () => { return promise }) }) + +it(`shouldSync if the wallet changes`, () => { + expect( + shouldSync({ + newAuthenticated: true, + newWallet: 43, + oldAuthenticated: true, + oldWallet: 42 + }) + ).toEqual(true) +}) diff --git a/main-process/packages/web-microkernel b/main-process/packages/web-microkernel new file mode 120000 index 00000000000..0c8cc3cca44 --- /dev/null +++ b/main-process/packages/web-microkernel @@ -0,0 +1 @@ +../../packages/web-microkernel \ No newline at end of file diff --git a/main-process/yarn.lock b/main-process/yarn.lock index b454ce5e8cc..696e059bd63 100644 --- a/main-process/yarn.lock +++ b/main-process/yarn.lock @@ -1419,6 +1419,13 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@nodeguy/channel@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@nodeguy/channel/-/channel-0.6.4.tgz#84d64b513d17c35b599e74e7a16101945f62ae43" + integrity sha512-TT4kEgcj8K9Gj7fzq1m/Nb/xoyTYEc5C27WZPBEebQXQoLeZtoytKi1pYZ6vU3tn/nkj1fkS1sLCJ5w5R/2JpA== + dependencies: + setimmediate "1.0.5" + "@nodelib/fs.stat@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26" @@ -10122,7 +10129,7 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" -lodash-es@^4.17.10: +lodash-es@4.17.11, lodash-es@^4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== @@ -13874,7 +13881,7 @@ set-value@^2.0.0: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +setimmediate@1.0.5, setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU= diff --git a/packages/blockchain-wallet-v4-frontend/package.json b/packages/blockchain-wallet-v4-frontend/package.json index 9aea4db5df6..69f745e29b2 100644 --- a/packages/blockchain-wallet-v4-frontend/package.json +++ b/packages/blockchain-wallet-v4-frontend/package.json @@ -88,6 +88,7 @@ "@ledgerhq/hw-app-btc": "4.30.0", "@ledgerhq/hw-app-str": "4.26.0-beta.ebeb3540", "@ledgerhq/hw-transport-u2f": "4.31.0", + "@nodeguy/channel": "0.6.4", "awesome-phonenumber": "2.2.6", "base-64": "0.1.0", "bignumber.js": "8.0.1", diff --git a/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js b/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js index c905bf90105..119fef2b1b3 100644 --- a/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js +++ b/packages/blockchain-wallet-v4-frontend/src/data/logs/reducers.js @@ -18,6 +18,7 @@ const logger = (state = INITIAL_STATE, action) => { switch (type) { case AT.LOG_ERROR_MSG: { + console.error(payload) return insert(0, createLog('ERROR', payload), state) } case AT.LOG_INFO_MSG: { diff --git a/packages/blockchain-wallet-v4-frontend/src/index.html b/packages/blockchain-wallet-v4-frontend/src/index.html index 7858507df44..26948bdcb14 100644 --- a/packages/blockchain-wallet-v4-frontend/src/index.html +++ b/packages/blockchain-wallet-v4-frontend/src/index.html @@ -1,27 +1,63 @@ - - - - - - - - - - - - - - - - - Blockchain Wallet - Exchange Cryptocurrency - - - -
- + + + + +
+ + diff --git a/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js b/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js new file mode 100644 index 00000000000..64a7efe5ecd --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/middleware/actionTypes.js @@ -0,0 +1 @@ +export const FORWARD = `FORWARD` diff --git a/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js b/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js new file mode 100644 index 00000000000..2018d400603 --- /dev/null +++ b/packages/blockchain-wallet-v4-frontend/src/middleware/forwardActions.js @@ -0,0 +1,11 @@ +import { FORWARD } from './actionTypes' + +export default ({ forward, types }) => () => next => action => { + if (types.has(action.type)) { + forward(action) + } else if (action.type === FORWARD) { + forward(action.payload) + } + + return next(action) +} diff --git a/packages/blockchain-wallet-v4-frontend/src/middleware/index.js b/packages/blockchain-wallet-v4-frontend/src/middleware/index.js index 07c8c525f27..52d564ffca7 100644 --- a/packages/blockchain-wallet-v4-frontend/src/middleware/index.js +++ b/packages/blockchain-wallet-v4-frontend/src/middleware/index.js @@ -1,4 +1,5 @@ import autoDisconnection from './autoDisconnection' +import forwardActions from './forwardActions' import webSocketBch from './webSocketBch' import webSocketBtc from './webSocketBtc' import webSocketEth from './webSocketEth' @@ -7,6 +8,7 @@ import streamingXlm from './streamingXlm' export { autoDisconnection, + forwardActions, streamingXlm, webSocketBch, webSocketBtc, diff --git a/packages/blockchain-wallet-v4-frontend/src/store/index.js b/packages/blockchain-wallet-v4-frontend/src/store/index.js index 2efff0316cf..3f069d35366 100644 --- a/packages/blockchain-wallet-v4-frontend/src/store/index.js +++ b/packages/blockchain-wallet-v4-frontend/src/store/index.js @@ -1,15 +1,22 @@ +import axios from 'axios' +import Channel from '@nodeguy/channel' import { createStore, applyMiddleware, compose } from 'redux' import createSagaMiddleware from 'redux-saga' import { persistStore, persistCombineReducers } from 'redux-persist' +import { RealmConnection, Multiplexed } from '../../../web-microkernel/src' import storage from 'redux-persist/lib/storage' import getStoredStateMigrateV4 from 'redux-persist/lib/integration/getStoredStateMigrateV4' import { createHashHistory } from 'history' import { connectRouter, routerMiddleware } from 'connected-react-router' -import { head } from 'ramda' +import { always, evolve, head, omit, pick } from 'ramda' import Bitcoin from 'bitcoinjs-lib' import BitcoinCash from 'bitcoinforksjs-lib' import { coreMiddleware } from 'blockchain-wallet-v4/src' +import { + SYNC_ERROR, + SYNC_SUCCESS +} from 'blockchain-wallet-v4/src/redux/walletSync/actionTypes' import { createWalletApi, Socket, @@ -20,6 +27,7 @@ import { serializer } from 'blockchain-wallet-v4/src/types' import { actions, rootSaga, rootReducer, selectors } from 'data' import { autoDisconnection, + forwardActions, streamingXlm, webSocketBch, webSocketBtc, @@ -29,6 +37,7 @@ import { const devToolsConfig = { maxAge: 1000, + name: `Root Document`, serialize: serializer, actionsBlacklist: [ // '@@redux-form/INITIALIZE', @@ -43,7 +52,7 @@ const devToolsConfig = { ] } -const configureStore = () => { +const configureStore = async () => { const history = createHashHistory() const sagaMiddleware = createSagaMiddleware() const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ @@ -53,108 +62,169 @@ const configureStore = () => { const kvStorePath = 'wallet.kvstore' const isAuthenticated = selectors.auth.isAuthenticated - return fetch('/Resources/wallet-options-v4.json') - .then(res => res.json()) - .then(options => { - const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' - // TODO: deprecate when wallet-options-v4 is updated on prod - const socketUrl = head(options.domains.webSocket.split('/inv')) - const horizonUrl = options.domains.horizon - const btcSocket = new Socket({ - options, - url: `${socketUrl}/inv` - }) - const bchSocket = new Socket({ - options, - url: `${socketUrl}/bch/inv` - }) - const ethSocket = new Socket({ - options, - url: `${socketUrl}/eth/inv` - }) - const ratesSocket = new ApiSocket({ - options, - url: `${socketUrl}/nabu-gateway/markets/quotes`, - maxReconnects: 3 - }) - const xlmStreamingService = new HorizonStreamingService({ - url: horizonUrl - }) - const getAuthCredentials = () => - selectors.modules.profile.getAuthCredentials(store.getState()) - const reauthenticate = () => - store.dispatch(actions.modules.profile.signIn()) - const networks = { - btc: Bitcoin.networks[options.platforms.web.btc.config.network], - bch: BitcoinCash.networks[options.platforms.web.btc.config.network], - bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], - eth: options.platforms.web.eth.config.network, - xlm: options.platforms.web.xlm.config.network + const options = await (await fetch( + '/Resources/wallet-options-v4.json' + )).json() + + const apiKey = '1770d5d9-bcea-4d28-ad21-6cbd5be018a8' + // TODO: deprecate when wallet-options-v4 is updated on prod + const socketUrl = head(options.domains.webSocket.split('/inv')) + const horizonUrl = options.domains.horizon + const btcSocket = new Socket({ + options, + url: `${socketUrl}/inv` + }) + const bchSocket = new Socket({ + options, + url: `${socketUrl}/bch/inv` + }) + const ethSocket = new Socket({ + options, + url: `${socketUrl}/eth/inv` + }) + const ratesSocket = new ApiSocket({ + options, + url: `${socketUrl}/nabu-gateway/markets/quotes`, + maxReconnects: 3 + }) + const xlmStreamingService = new HorizonStreamingService({ + url: horizonUrl + }) + + // Omit properties that can't be serialized. + const sanitizedAxios = async config => { + try { + const returnValue = await axios(config) + const sanitizedReturnValue = omit([`config`, `request`], returnValue) + return sanitizedReturnValue + } catch (exception) { + const redact = always(undefined) + + const mask = { + config: redact, + request: redact, + response: { config: redact, request: redact } } - const api = createWalletApi({ - options, - apiKey, - getAuthCredentials, - reauthenticate, - networks - }) - const persistWhitelist = ['session', 'preferences', 'cache'] - - // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) - const store = createStore( - connectRouter(history)( - persistCombineReducers( - { - getStoredState: getStoredStateMigrateV4({ - whitelist: persistWhitelist - }), - key: 'root', - storage, - whitelist: persistWhitelist - }, - rootReducer - ) - ), - composeEnhancers( - applyMiddleware( - sagaMiddleware, - routerMiddleware(history), - coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), - webSocketBtc(btcSocket), - webSocketBch(bchSocket), - webSocketEth(ethSocket), - streamingXlm(xlmStreamingService, api), - webSocketRates(ratesSocket), - coreMiddleware.walletSync({ isAuthenticated, api, walletPath }), - autoDisconnection() - ) - ) + + const sanitizedException = evolve(mask, exception) + throw Object.assign(Error(exception.message), sanitizedException) + } + } + + // The store isn't available by the time we want to export its dispatch method + // so use a channel to hold pending actions. + const actionsChannel = Channel() + + const mainProcessWindow = document.getElementById(`main-process`) + .contentWindow + + const mainProcess = await RealmConnection({ + exports: { axios: sanitizedAxios, dispatch: actionsChannel.push }, + input: Multiplexed({ realm: window, tag: `realms` }), + output: Multiplexed({ realm: mainProcessWindow, tag: `realms` }), + outputOrigin: options.domains.mainProcess, + reviver: serializer.reviver + }) + + mainProcess.addEventListener(`error`, event => { + const plainError = pick( + [`message`, `filename`, `lineno`, `colno`, `error`], + event + ) + + store.dispatch( + actions.logs.logErrorMessage(`store`, `realm connection`, plainError) + ) + }) + + const getAuthCredentials = () => + selectors.modules.profile.getAuthCredentials(store.getState()) + const reauthenticate = () => store.dispatch(actions.modules.profile.signIn()) + const networks = { + btc: Bitcoin.networks[options.platforms.web.btc.config.network], + bch: BitcoinCash.networks[options.platforms.web.btc.config.network], + bsv: BitcoinCash.networks[options.platforms.web.btc.config.network], + eth: options.platforms.web.eth.config.network, + xlm: options.platforms.web.xlm.config.network + } + const api = createWalletApi({ + options, + apiKey, + getAuthCredentials, + reauthenticate, + networks + }) + const persistWhitelist = ['session', 'preferences', 'cache'] + + // Forward the following action types to the main process. + // + // @CORE.SYNC_ERROR: Report failure of wallet synchronization. + // @CORE.SYNC_SUCCESS: Report success of wallet synchronization. + const forwardActionTypes = new Set([SYNC_ERROR, SYNC_SUCCESS]) + + // TODO: remove getStoredStateMigrateV4 someday (at least a year from now) + const store = createStore( + connectRouter(history)( + persistCombineReducers( + { + getStoredState: getStoredStateMigrateV4({ + whitelist: persistWhitelist + }), + key: 'root', + storage, + whitelist: persistWhitelist + }, + rootReducer ) - const persistor = persistStore(store, null) - - sagaMiddleware.run(rootSaga, { - api, - bchSocket, - btcSocket, - ethSocket, - ratesSocket, - networks, - options - }) - - // expose globals here - window.createTestXlmAccounts = () => { - store.dispatch(actions.core.data.xlm.createTestAccounts()) - } + ), + composeEnhancers( + applyMiddleware( + sagaMiddleware, + routerMiddleware(history), + coreMiddleware.kvStore({ isAuthenticated, api, kvStorePath }), + webSocketBtc(btcSocket), + webSocketBch(bchSocket), + webSocketEth(ethSocket), + streamingXlm(xlmStreamingService, api), + webSocketRates(ratesSocket), + coreMiddleware.walletSync({ isAuthenticated, api, walletPath }), + autoDisconnection(), + + forwardActions({ + forward: mainProcess.imports.dispatch, + types: forwardActionTypes + }) + ) + ) + ) + const persistor = persistStore(store, null) - store.dispatch(actions.goals.defineGoals()) + sagaMiddleware.run(rootSaga, { + api, + bchSocket, + btcSocket, + ethSocket, + ratesSocket, + networks, + options + }) - return { - store, - history, - persistor - } - }) + // Now that we have a store, dispatch pending and future actions from the + // channel. + actionsChannel.forEach(store.dispatch) + + // expose globals here + window.createTestXlmAccounts = () => { + store.dispatch(actions.core.data.xlm.createTestAccounts()) + } + + store.dispatch(actions.goals.defineGoals()) + + return { + store, + history, + persistor + } } export default configureStore diff --git a/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js b/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js index ca6f7010fc1..1bf71ad2cd4 100644 --- a/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js +++ b/packages/blockchain-wallet-v4-frontend/webpack.config.dev.js @@ -199,6 +199,7 @@ module.exports = { app.get('/Resources/wallet-options-v4.json', function(req, res) { // combine wallet options base with custom environment config mockWalletOptions.domains = { + mainProcess: `http://localhost:8082`, root: envConfig.ROOT_URL, api: envConfig.API_DOMAIN, webSocket: envConfig.WEB_SOCKET_URL, @@ -257,7 +258,7 @@ module.exports = { "style-src 'self' 'unsafe-inline'", `frame-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} ${ envConfig.ROOT_URL - } https://magic.veriff.me https://localhost:8080 http://localhost:8080`, + } https://magic.veriff.me https://localhost:8080 http://localhost:8080 https://localhost:8082 http://localhost:8082`, `child-src ${iSignThisDomain} ${envConfig.WALLET_HELPER_DOMAIN} blob:`, [ 'connect-src', diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap b/packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap new file mode 100644 index 00000000000..3169e4b7e68 --- /dev/null +++ b/packages/blockchain-wallet-v4/src/redux/wallet/__snapshots__/walletReducers.spec.js.snap @@ -0,0 +1,86 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`reducers wallet should handle MERGE_WRAPPER 1`] = ` +Immutable.Map { + "sync_pubkeys": false, + "payload_checksum": "payload_checksum", + "storage_token": "storage_token", + "version": 3, + "language": "en", + "wallet": Immutable.Map { + "addresses": Immutable.Map { + "19XmKRY66VnUn5irHAafyoTfiwFuGLUxKF": Immutable.Map { + "addr": "19XmKRY66VnUn5irHAafyoTfiwFuGLUxKF", + "priv": "38W3vsxt246Lhawvz81y2HQVnsJrBUX87jSTUUnixPsE", + "tag": 0, + "label": "", + "created_time": 1492721419269, + "created_device_name": "javascript_web", + "created_device_version": "3.0", + }, + "14mQxLtEagsS8gYsdWJbzthFFuPDqDgtxQ": Immutable.Map { + "addr": "14mQxLtEagsS8gYsdWJbzthFFuPDqDgtxQ", + "priv": "BpD2ZuJjZ8PJPpDX6ZmsKFsXHkL7XV3dt385zghMfF6C", + "tag": 0, + "label": "labeled_imported", + "created_time": 1492721432222, + "created_device_name": "javascript_web", + "created_device_version": "3.0", + }, + "1JD73aGSdeqKUjJ4ntP4eCyUiuZ3ogJE1u": Immutable.Map { + "addr": "1JD73aGSdeqKUjJ4ntP4eCyUiuZ3ogJE1u", + "priv": null, + "tag": 0, + "label": "", + "created_time": 1492721461228, + "created_device_name": "javascript_web", + "created_device_version": "3.0", + }, + }, + "tx_notes": Immutable.Map {}, + "guid": "current", + "metadataHDNode": "xprv9tygGQP8be7uzNm5Czuy41juTK9pUKnWyZtDxgbmSEcCYa9VdvvtSknEyiKitqqm2TMv14NjXPQ68XLwSdH6Scc5GwXoZ31yRZZysxhVGU7", + "tx_names": Immutable.List [], + "double_encryption": false, + "address_book": Immutable.Map {}, + "hd_wallets": Immutable.List [ + Immutable.Map { + "seed_hex": "current", + "seedHex": "6a4d9524d413fdf69ca1b5664d1d6db0", + "passphrase": "", + "mnemonic_verified": false, + "default_account_idx": 0, + "accounts": Immutable.List [ + Immutable.Map { + "label": "My Bitcoin Wallet", + "archived": false, + "xpriv": "xprv9yL1ousLjQQzGNBAYykaT8J3U626NV6zbLYkRv8rvUDpY4f1RnrvAXQneGXC9UNuNvGXX4j6oHBK5KiV2hKevRxY5ntis212oxjEL11ysuG", + "xpub": "xpub6CKNDRQEZmyHUrFdf1HapGEn27ramwpqxZUMEJYUUokoQrz9yLBAiKjGVWDuiCT39udj1r3whqQN89Tar5KrojH8oqSy7ytzJKW8gwmhwD3", + "address_labels": Immutable.Map { + "0": Immutable.Map { + "index": 0, + "label": "labeled_address", + }, + }, + "cache": Immutable.Map { + "receiveAccount": "xpub6F41z8MqNcJMvKQgAd5QE2QYo32cocYigWp1D8726ykMmaMqvtqLkvuL1NqGuUJvU3aWyJaV2J4V6sD7Pv59J3tYGZdYRSx8gU7EG8ZuPSY", + "changeAccount": "xpub6F41z8MqNcJMwmeUExdCv7UXvYBEgQB29SWq9jyxuZ7WefmSTWcwXB6NRAJkGCkB3L1Eu4ttzWnPVKZ6REissrQ4i6p8gTi9j5YwDLxmZ8p", + }, + "index": 0, + }, + ], + }, + ], + "sharedKey": "8a260b2b-5257-4357-ac56-7a7efca323ea", + "options": Immutable.Map { + "pbkdf2_iterations": 5000, + "fee_per_kb": 10000, + "html5_notifications": false, + "logout_time": 600000, + }, + }, + "war_checksum": "war_checksum", + "password": "current", + "pbkdf2_iterations": 5000, +} +`; diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js b/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js index 1e71c3b27bd..6211d6c7718 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/actionTypes.js @@ -1,4 +1,5 @@ // setters +export const MERGE_WRAPPER = '@CORE.MERGE_WRAPPER' export const SET_WRAPPER = '@CORE.SET_WRAPPER' export const REFRESH_WRAPPER = '@CORE.REFRESH_WRAPPER' export const SET_MAIN_PASSWORD = '@CORE.SET_MAIN_PASSWORD' diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/actions.js b/packages/blockchain-wallet-v4/src/redux/wallet/actions.js index 624d77e3563..52adff0b5fa 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/actions.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/actions.js @@ -1,6 +1,11 @@ import * as T from './actionTypes' // setters +export const mergeWrapper = payload => ({ + type: T.MERGE_WRAPPER, + payload +}) + export const setWrapper = payload => ({ type: T.SET_WRAPPER, payload: payload diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js b/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js index e432a1e65e3..5753acd4b30 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/reducers.js @@ -1,5 +1,5 @@ import { over, set } from 'ramda-lens' -import { compose } from 'ramda' +import { always, compose, evolve } from 'ramda' import * as T from './actionTypes.js' import { Wrapper, Wallet, Options, HDWallet, HDWalletList } from '../../types' @@ -8,6 +8,31 @@ export const WRAPPER_INITIAL_STATE = Wrapper.fromJS( Wrapper.createNewReadOnly('', '') ) +// an object containing reducer methods for each type of action +const reducers = {} + +// MERGE_WRAPPER: Merge wrapper from the Main Process. + +const redact = always(undefined) + +const wrapperMask = { + password: redact, + wallet: { + guid: redact, + hd_wallets: [{ seed_hex: redact }] + } +} + +const wrapperMerger = (oldValue, newValue) => + newValue === undefined ? oldValue : newValue + +reducers[T.MERGE_WRAPPER] = (state, { payload }) => { + const redactedPayload = Wrapper.fromJS(evolve(wrapperMask, payload)) + return state.mergeDeepWith(wrapperMerger, redactedPayload) +} + +// + export const wrapperReducer = (state = WRAPPER_INITIAL_STATE, action) => { const { type } = action switch (type) { @@ -110,8 +135,13 @@ export const wrapperReducer = (state = WRAPPER_INITIAL_STATE, action) => { ) return set(mvLens, true, state) } - default: - return state + default: { + if (type in reducers) { + return reducers[type](state, action) + } else { + return state + } + } } } diff --git a/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js b/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js index 18c048cfe29..7cd36a57c61 100755 --- a/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js +++ b/packages/blockchain-wallet-v4/src/redux/wallet/walletReducers.spec.js @@ -1,3 +1,4 @@ +import { fromJS } from 'immutable' import { compose } from 'ramda' import { Wrapper, Wallet, AddressMap } from '../../types' import walletReducer from './reducers.js' @@ -20,6 +21,22 @@ describe('reducers', () => { describe('wallet', () => { const wrapped = Wrapper.fromJS(wrap(walletFixture)) + it('should handle MERGE_WRAPPER', () => { + const state = fromJS({ + password: `current`, + version: 4, + wallet: { + guid: `current`, + hd_wallets: [{ seed_hex: `current` }], + sharedKey: `current` + } + }) + + const action = Actions.mergeWrapper(wrapped.toJS()) + const next = walletReducer(state, action) + expect(next).toMatchSnapshot() + }) + it('should handle SET_WRAPPER', () => { let action = Actions.setWrapper(wrapped) let next = walletReducer(void 0, action) diff --git a/packages/blockchain-wallet-v4/src/types/Serializer.js b/packages/blockchain-wallet-v4/src/types/Serializer.js index 2eb76750fc7..27c4a4a5c93 100755 --- a/packages/blockchain-wallet-v4/src/types/Serializer.js +++ b/packages/blockchain-wallet-v4/src/types/Serializer.js @@ -1,3 +1,5 @@ +import * as R from 'ramda' + import * as Wrapper from './Wrapper' import * as HDWallet from './HDWallet' import * as HDAccount from './HDAccount' @@ -17,6 +19,11 @@ import * as Options from './Options' import * as KVStoreEntry from './KVStoreEntry' import Remote from '../remote' +const replaceError = error => ({ + ...R.pick([`message`, `stack`], error), + ...error +}) + const serializer = { replacer: function (key, value) { // Remove all functions from the state @@ -27,7 +34,8 @@ const serializer = { if (key === 'syncErrors') { return '' } - return value + + return value instanceof Error ? replaceError(value) : value }, reviver: function (key, value) { if ( diff --git a/packages/web-microkernel/package.json b/packages/web-microkernel/package.json new file mode 100644 index 00000000000..53fd5419f44 --- /dev/null +++ b/packages/web-microkernel/package.json @@ -0,0 +1,9 @@ +{ + "name": "web-microkernel", + "version": "1.0.0", + "main": "index.js", + "license": "MIT", + "dependencies": { + "lodash-es": "4.17.11" + } +} diff --git a/packages/web-microkernel/src/Multiplexed.js b/packages/web-microkernel/src/Multiplexed.js new file mode 100644 index 00000000000..406a5b3da49 --- /dev/null +++ b/packages/web-microkernel/src/Multiplexed.js @@ -0,0 +1,39 @@ +// Multiplex `postMessage` and the `message` event so that multiple agents can +// share the communication channel between realms. +export default ({ realm, tag }) => { + const listeners = new Map() + + return { + addEventListener: (event, listener, useCapture = false) => { + // Multiplex only message events. + if (event === `message`) { + const multiplexListener = ({ data }) => { + // Listen only to appropriately tagged messages and ignore the rest. + if (data.type === tag) { + return listener({ data: data.data }) + } + } + + // Remember this listener so that we can remove it later. + listeners.set(listener, multiplexListener) + + return realm.addEventListener(`message`, multiplexListener, useCapture) + } else { + return realm.addEventListener(event, listener, useCapture) + } + }, + + postMessage: (message, targetOrigin) => + realm.postMessage({ type: tag, data: message }, targetOrigin), + + removeEventListener: (event, listener) => { + if (event === `message`) { + const result = realm.removeEventListener(event, listeners.get(listener)) + listeners.delete(listener) + return result + } else { + return realm.removeEventListener(event, listener) + } + } + } +} diff --git a/packages/web-microkernel/src/Multiplexed.test.js b/packages/web-microkernel/src/Multiplexed.test.js new file mode 100644 index 00000000000..2a66d8c4017 --- /dev/null +++ b/packages/web-microkernel/src/Multiplexed.test.js @@ -0,0 +1,103 @@ +import Multiplexed from './Multiplexed.js' + +// a cheap exercise of the destructured clone operation that occurs between +// realm boundaries +const DestructuredClone = () => { + const resolves = [] + + window.addEventListener(`message`, ({ data }) => { + const resolve = resolves.shift() + resolve(data) + }) + + return value => + new Promise(resolve => { + resolves.push(resolve) + window.postMessage(value) + }) +} + +const destructuredClone = DestructuredClone() + +const MockRealm = origin => { + let target + + return Object.assign(new EventTarget(), { + // Connect to another realm. + connect: realm => { + target = realm + }, + + postMessage: async (message, targetOrigin) => { + console.log(`postMessage`, message) + expect(targetOrigin).toEqual(origin) + + if (target) { + const event = new MessageEvent(`message`, { + data: await destructuredClone(message) + }) + + target.dispatchEvent(event) + } + } + }) +} + +const createMockRealms = () => { + const a = MockRealm(`a`) + const b = MockRealm(`b`) + a.connect(b) + b.connect(a) + return { a, b } +} + +it(`multiplexes use of a realm with a wrapper`, async () => { + const { a, b } = createMockRealms() + + const sender1 = Multiplexed({ realm: a, tag: `multiplexed 1` }) + const sender2 = Multiplexed({ realm: a, tag: `multiplexed 2` }) + const receiver1 = Multiplexed({ realm: b, tag: `multiplexed 1` }) + const receiver2 = Multiplexed({ realm: b, tag: `multiplexed 2` }) + + let reject1 + let resolve1 + let reject2 + let resolve2 + + const listener1 = ({ data }) => { + try { + expect(data).toEqual(`multiplexed 1`) + resolve1() + } catch (exception) { + reject1(exception) + } + } + + const listener2 = ({ data }) => { + try { + expect(data).toEqual(`multiplexed 2`) + resolve2() + } catch (exception) { + reject2(exception) + } + } + + const promises = Promise.all([ + new Promise((resolve, reject) => { + resolve1 = resolve + reject1 = reject + }), + new Promise((resolve, reject) => { + resolve2 = resolve + reject2 = reject + }) + ]) + + receiver1.addEventListener(`message`, listener1) + receiver2.addEventListener(`message`, listener2) + sender1.postMessage(`multiplexed 1`, `a`) + sender2.postMessage(`multiplexed 2`, `a`) + await promises + receiver1.removeEventListener(`message`, listener1) + receiver2.removeEventListener(`message`, listener2) +}) diff --git a/packages/web-microkernel/src/RealmConnection.js b/packages/web-microkernel/src/RealmConnection.js new file mode 100644 index 00000000000..1df5bbf2399 --- /dev/null +++ b/packages/web-microkernel/src/RealmConnection.js @@ -0,0 +1,409 @@ +import * as _ from './lodash-es/lodash.js' + +// Polyfill the behavior of addEventListener(type, listener, { once: true })) +// because for an unknown reason the above isn't working in Chrome 72. +const addEventListenerOnce = (target, type, listener) => { + const onceListener = (...args) => { + const result = listener(...args) + target.removeEventListener(type, onceListener) + return result + } + + target.addEventListener(type, onceListener) +} + +const firstEvent = (target, type) => + new Promise(resolve => { + addEventListenerOnce(target, type, resolve) + }) + +const inspectionCutoff = 40 + +export const inspect = value => { + const stringified = JSON.stringify(value) + + return stringified.length > inspectionCutoff + ? `${stringified.slice(0, inspectionCutoff)}...` + : stringified +} + +// Create an unforgeable key. +const Key = () => { + const array = new Uint32Array(1) + window.crypto.getRandomValues(array) + return array[0].toString(36) +} + +const decoders = {} +const types = [] + +// The types are defined in the same order they are tested. + +types.push( + { + name: `boolean`, + test: _.isBoolean, + encode: (context, boolean) => boolean, + decode: (context, code) => code + }, + + { + name: `null`, + test: _.isNull, + encode: () => null, + decode: () => null + }, + + { + name: `number`, + test: _.isNumber, + encode: (context, number) => number, + decode: (context, code) => code + }, + + { + name: `string`, + test: _.isString, + encode: (context, string) => string, + decode: (context, code) => code + }, + + { + name: `undefined`, + test: _.isUndefined, + encode: () => undefined, + decode: () => undefined + }, + + { + name: `array`, + test: Array.isArray, + encode: (context, array) => + array.map((value, index) => + context.memoizedEncode(context, value, index) + ), + + decode: (context, codes) => + codes.map((code, index) => context.memoizedDecode(context, code, index)) + } +) + +// error + +const isError = value => value instanceof Error + +const encodeError = (context, error) => ({ + // The `message` property has to be handled separately. + message: context.memoizedEncode(context, error.message), + + // Encode all attributes (not just standard `message` and `name`) for + // the benefit of Axios. + pairs: Object.entries(error).map(([key, value]) => [ + key, + context.memoizedEncode(context, value, key) + ]) +}) + +const decodeError = (context, { pairs, message }) => + Object.assign( + Error(context.memoizedDecode(context, message)), + ...pairs.map(([key, value]) => ({ + [key]: context.memoizedDecode(context, value, key) + })) + ) + +types.push({ + name: `error`, + test: isError, + encode: encodeError, + decode: decodeError +}) + +// function + +const encodeFunction = ({ functionKeys, keyedReferences }, func) => { + if (!keyedReferences) { + throw new TypeError(`Cannot encode functions outside of exports.`) + } + + if (!functionKeys.has(func)) { + const key = Key() + functionKeys.set(func, key) + keyedReferences[key] = func + } + + return { key: functionKeys.get(func), length: func.length } +} + +const decodeFunction = (context, { key: functionKey, length }) => { + const { keyedReferences, reportExceptionsIn } = context + + if (!(functionKey in keyedReferences)) { + const proxyFunction = (...args) => + new Promise( + reportExceptionsIn((resolve, reject) => { + const returnValueKey = Key() + keyedReferences[returnValueKey] = { resolve, reject } + + // Function application isn't a type so encode its dictionary + // manually. + context.postMessage([ + `functionApply`, + { + args: encodeWithoutPersistentReferences(context, args), + functionKey, + returnValueKey + } + ]) + }) + ) + + Object.defineProperty(proxyFunction, `length`, { value: length }) + keyedReferences[functionKey] = proxyFunction + } + + return keyedReferences[functionKey] +} + +decoders.functionApply = (context, { args, functionKey, returnValueKey }) => { + const { keyedReferences, postMessage, reportExceptionsIn } = context + const func = keyedReferences[functionKey] + const decodedArgs = decode(context, args) + + const functionReturn = encoding => { + // Function return isn't a type so encode its dictionary manually. + postMessage([`functionReturn`, { returnValueKey, ...encoding }]) + } + + const resolve = reportExceptionsIn(value => { + functionReturn({ value: encodeWithoutPersistentReferences(context, value) }) + }) + + const reject = reportExceptionsIn(reason => { + functionReturn({ + reason: encodeWithoutPersistentReferences(context, reason) + }) + }) + + try { + func(...decodedArgs).then(resolve, reject) + } catch (exception) { + throw new TypeError( + `Only asynchronous functions can be called across realms.` + ) + } +} + +decoders.functionReturn = (context, { returnValueKey, reason, value }) => { + const { keyedReferences } = context + const { reject, resolve } = keyedReferences[returnValueKey] + delete keyedReferences[returnValueKey] + + if (reason) { + reject(decode(context, reason)) + } else { + resolve(decode(context, value)) + } +} + +types.push({ + name: `function`, + test: _.isFunction, + encode: encodeFunction, + decode: decodeFunction +}) + +// map + +const encodeMap = (context, map) => + [...map.entries()].map(([key, value]) => [ + context.memoizedEncode(context, key), + context.memoizedEncode(context, value, key) + ]) + +const decodeMap = (context, pairs) => + new Map( + pairs.map(([encodedKey, encodedValue]) => { + const key = context.memoizedDecode(context, encodedKey) + const value = context.memoizedDecode(context, encodedValue, key) + return [key, value] + }) + ) + +types.push({ + name: `map`, + test: _.isMap, + encode: encodeMap, + decode: decodeMap +}) + +// set + +const encodeSet = (context, set) => + [...set].map(value => context.memoizedEncode(context, value)) + +const decodeSet = (context, codes) => + new Set(codes.map(code => context.memoizedDecode(context, code))) + +types.push({ + name: `set`, + test: _.isSet, + encode: encodeSet, + decode: decodeSet +}) + +// object + +const encodeObject = (context, object) => + Object.entries(object).map(([key, value]) => [ + key, + context.memoizedEncode(context, value, key) + ]) + +const decodeObject = (context, pairs) => + Object.assign( + {}, + ...pairs.map(([key, encodedValue]) => ({ + [key]: context.memoizedDecode(context, encodedValue, key) + })) + ) + +types.push({ + name: `object`, + test: _.isPlainObject, + encode: encodeObject, + decode: decodeObject +}) + +// end of type definitions + +types.forEach(({ name, decode }) => { + decoders[name] = decode +}) + +const encodeWithType = (context, value, key = ``) => { + let json + + try { + json = value.toJSON(key) + } catch (exception) { + const type = types.find(({ test }) => test(value)) + + if (type) { + return [type.name, type.encode(context, value)] + } else { + throw new TypeError(`Don't know how to encode "${inspect(value)}".`) + } + } + + return encodeWithType(context, json, key) +} + +const memoizeResolver = (context, value) => value + +const encode = (context, value) => { + const memoizedEncode = _.memoize(encodeWithType, memoizeResolver) + + try { + return memoizedEncode({ ...context, memoizedEncode }, value) + } catch (exception) { + throw Object.assign( + new Error(`Exception while encoding ${inspect(value)}`), + { exception, value } + ) + } +} + +const encodeWithoutPersistentReferences = (context, value) => + encode( + { + ...context, + functionKeys: null, + keyedReferences: null + }, + value + ) + +const decodeFromType = (context, code, key = ``) => { + const [type, encoding] = code + const decode = decoders[type] + + if (decode === undefined) { + throw new TypeError(`Don't know how to decode type ${inspect(type)}.`) + } + + // Freeze the newly created value because it's read-only: Changes wouldn't + // otherwise propogate back to the original value in the other realm. + return Object.freeze(context.reviver(key, decode(context, encoding))) +} + +const decode = (context, code) => { + const memoizedDecode = _.memoize(decodeFromType, memoizeResolver) + return memoizedDecode({ ...context, memoizedDecode }, code) +} + +const defaultReviver = (key, value) => value + +export default async ({ + exports, + input, + output, + outputOrigin, + reviver = defaultReviver +}) => { + const postMessage = message => { + // console.log(`-> ${outputOrigin} ${JSON.stringify(message)}`) + output.postMessage(message, outputOrigin) + } + + const eventTarget = new EventTarget() + + const reportExceptionsIn = callback => (...args) => { + try { + return callback(...args) + } catch (exception) { + eventTarget.dispatchEvent( + new ErrorEvent(`error`, { + error: exception, + message: exception.message + }) + ) + } + } + + const context = { + functionKeys: new Map(), + keyedReferences: {}, + postMessage, + reportExceptionsIn, + reviver + } + + const postExports = () => postMessage(encode(context, exports)) + postExports() + const { data } = await firstEvent(input, `message`) + const imports = decode(context, data) + + // We've already posted our exports but post them a second time in case the + // other realm wasn't listening yet. The fact that we've received a handshake + // means they're listening now. Posting the exports is idempotent. + postExports() + + // Now that we've completed the handshake, listen for all future message + // events. + + const messageListener = reportExceptionsIn(({ data }) => { + // console.log(`<- ${JSON.stringify(data)}`) + decode(context, data) + }) + + input.addEventListener(`message`, messageListener) + + return Object.assign(eventTarget, { + close: () => { + input.removeEventListener(`message`, messageListener) + }, + + imports + }) +} diff --git a/packages/web-microkernel/src/RealmConnection.test.js b/packages/web-microkernel/src/RealmConnection.test.js new file mode 100644 index 00000000000..2b84b9606e5 --- /dev/null +++ b/packages/web-microkernel/src/RealmConnection.test.js @@ -0,0 +1,390 @@ +import Connection, * as internal from './RealmConnection.js' + +it(`stringifies a value for debugging`, () => { + expect(internal.inspect([{ a: `This is a long string.` }])).toEqual( + `[{"a":"This is a long str...` + ) +}) + +// a cheap exercise of the destructured clone operation that occurs between +// realm boundaries +const DestructuredClone = () => { + const resolves = [] + + window.addEventListener(`message`, ({ data }) => { + const resolve = resolves.shift() + resolve(data) + }) + + return value => + new Promise(resolve => { + resolves.push(resolve) + window.postMessage(value) + }) +} + +const destructuredClone = DestructuredClone() + +const MockRealm = origin => { + let target + + return Object.assign(new EventTarget(), { + // Connect to another realm. + connect: realm => { + target = realm + }, + + postMessage: async (message, targetOrigin) => { + expect(targetOrigin).toEqual(origin) + + if (target) { + const event = new MessageEvent(`message`, { + data: await destructuredClone(message) + }) + + target.dispatchEvent(event) + } + } + }) +} + +const createMockRealms = () => { + const a = MockRealm(`a`) + const b = MockRealm(`b`) + a.connect(b) + b.connect(a) + return { a, b } +} + +xdescribe(`type checkers`, () => { + it(`array`, () => { + expect(realms.is.array(null)).toEqual(false) + expect(realms.is.array([1, 2, 3])).toEqual(true) + }) + + it(`boolean`, () => { + expect(realms.is.boolean(null)).toEqual(false) + expect(realms.is.boolean(true)).toEqual(true) + }) + + it(`function`, () => { + expect(realms.is.function(null)).toEqual(false) + expect(realms.is.function(() => {})).toEqual(true) + }) + + it(`map`, () => { + expect(realms.is.map(null)).toEqual(false) + expect(realms.is.map(new Map([[`a`, 1], [`b`, 2], [`c`, 3]]))).toEqual(true) + }) + + it(`null`, () => { + expect(realms.is.null(undefined)).toEqual(false) + expect(realms.is.null(null)).toEqual(true) + }) + + it(`number`, () => { + expect(realms.is.number(null)).toEqual(false) + expect(realms.is.number(42)).toEqual(true) + }) + + it(`plain object`, () => { + expect(realms.is.object(null)).toEqual(false) + expect(realms.is.object({ a: 1, b: 2, c: 3 })).toEqual(true) + }) + + it(`set`, () => { + expect(realms.is.set(null)).toEqual(false) + expect(realms.is.set(new Set([1, 2, 3]))).toEqual(true) + }) + + it(`string`, () => { + expect(realms.is.string(null)).toEqual(false) + expect(realms.is.string(`value`)).toEqual(true) + }) + + it(`undefined`, () => { + expect(realms.is.undefined(null)).toEqual(false) + expect(realms.is.undefined(undefined)).toEqual(true) + }) +}) + +// Create two test connections and serialize a value across them. +const serialize = async exports => { + const { a, b } = createMockRealms() + + const connection1Promise = Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2Promise = Connection({ + input: b, + output: b, + outputOrigin: `b` + }) + + const [connection1, connection2] = await Promise.all([ + connection1Promise, + connection2Promise + ]) + + connection1.addEventListener(`error`, console.error) + connection2.addEventListener(`error`, console.error) + + const close = () => { + connection1.close() + connection2.close() + } + + return { close, imports: connection2.imports } +} + +describe(`serializes values across realm boundaries`, () => { + it(`creates consistent object references`, async () => { + const object = { a: 1, b: 2, c: 3 } + const exports = [object, object] + const { close, imports } = await serialize(exports) + const [left, right] = imports + expect(left).toBe(right) + close() + }) + + it(`freezes serialized values`, async () => { + const exports = [{ a: 1 }] + const { close, imports } = await serialize(exports) + expect(Object.isFrozen(imports)).toEqual(true) + expect(Object.isFrozen(imports[0])).toEqual(true) + close() + }) + + it(`toJSON`, async () => { + const { a, b } = createMockRealms() + const wat = Symbol(`wat`) + + const exports = { + value: wat, + + toJSON: () => ({ + type: `symbol`, + description: `wat` + }) + } + + const reviver = (key, value) => + value.type === `symbol` && value.description === `wat` ? wat : value + + const connection1Promise = Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2 = await Connection({ + input: b, + output: b, + outputOrigin: `b`, + reviver + }) + + const connection1 = await connection1Promise + expect(connection2.imports).toEqual(wat) + connection1.close() + connection2.close() + }) + + it(`unsupported type`, async () => { + const { a } = createMockRealms() + const exports = Symbol(`description`) + + expectAsync( + Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + ).toBeRejected() + }) + + describe(`types`, () => { + it(`array`, async () => { + const exports = [1, 2, 3] + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`boolean`, async () => { + const exports = true + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`Error`, async () => { + const exports = Error(`message`) + exports.name = `name` + + // Axios adds extra properties to errors. + exports.extra = `extra` + + const { close, imports } = await serialize(exports) + expect(imports instanceof Error).toEqual(true) + expect(imports).toEqual(exports) + close() + }) + + describe(`function`, () => { + it(`has the same arguments length`, async () => { + const exports = (a, b) => a + b + const { close, imports } = await serialize(exports) + expect(imports.length).toEqual(exports.length) + close() + }) + + it(`encodes arguments`, async () => { + const exports = async (a, b) => [a, b] + const { close, imports } = await serialize(exports) + const object = {} + const [serializedA, serializedB] = await imports(object, object) + expect(serializedA).not.toBe(object) + expect(serializedA).toBe(serializedB) + close() + }) + + it(`returns value`, async () => { + const exports = async (a, b) => a + b + const { close, imports } = await serialize(exports) + expect(await imports(1, 2)).toEqual(3) + close() + }) + + it(`fails with synchronous functions`, async done => { + const { a, b } = createMockRealms() + const exports = (a, b) => a + b + + const connection1Promise = Connection({ + exports, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2 = await Connection({ + input: b, + output: b, + outputOrigin: `b` + }) + + const connection1 = await connection1Promise + connection2.imports() + + connection1.addEventListener(`error`, ({ message }) => { + expect(message).toEqual( + `Only asynchronous functions can be called across realms.` + ) + + connection1.close() + connection2.close() + done() + }) + }) + + it(`allows functions only within exports`, async done => { + const { a, b } = createMockRealms() + const compose = async (f, g) => x => f(g(x)) + + const connection1Promise = Connection({ + exports: compose, + input: a, + output: a, + outputOrigin: `a` + }) + + const connection2 = await Connection({ + input: b, + output: b, + outputOrigin: `b` + }) + + const connection1 = await connection1Promise + + connection2.addEventListener(`error`, ({ message }) => { + expect( + message.includes(`Cannot encode functions outside of exports.`) + ).toEqual(true) + + connection1.close() + connection2.close() + done() + }) + + connection2.imports(Math.cos, Math.sin) + }) + + it(`consistent identity`, async () => { + const func = async (a, b) => a + b + const exports = [func, func] + const { close, imports } = await serialize(exports) + const [left, right] = imports + expect(left).toBe(right) + close() + }) + + it(`throws exception`, async () => { + const exports = async () => { + throw new Error(`a tantrum`) + } + + const { close, imports } = await serialize(exports) + await expectAsync(imports()).toBeRejected() + close() + }) + }) + + it(`map`, async () => { + const exports = new Map([[`a`, 1], [`b`, 2], [`c`, 3]]) + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`null`, async () => { + const exports = null + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`number`, async () => { + const exports = 42 + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`object`, async () => { + const exports = { a: 1, b: 2, c: 3 } + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`set`, async () => { + const exports = new Set([1, 2, 3]) + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + + it(`undefined`, async () => { + const exports = undefined + const { close, imports } = await serialize(exports) + expect(imports).toEqual(exports) + close() + }) + }) +}) diff --git a/packages/web-microkernel/src/index.js b/packages/web-microkernel/src/index.js new file mode 100644 index 00000000000..ff7b8524480 --- /dev/null +++ b/packages/web-microkernel/src/index.js @@ -0,0 +1,4 @@ +import RealmConnection from './RealmConnection' +import Multiplexed from './Multiplexed' + +export { RealmConnection, Multiplexed } diff --git a/packages/web-microkernel/src/lodash-es b/packages/web-microkernel/src/lodash-es new file mode 120000 index 00000000000..6cafb512b62 --- /dev/null +++ b/packages/web-microkernel/src/lodash-es @@ -0,0 +1 @@ +../../../node_modules/lodash-es \ No newline at end of file diff --git a/packages/web-microkernel/wallaby.js b/packages/web-microkernel/wallaby.js new file mode 100644 index 00000000000..a3d7d7a5b96 --- /dev/null +++ b/packages/web-microkernel/wallaby.js @@ -0,0 +1,7 @@ +module.exports = wallaby => ({ + files: [`src/**/*.js`, `!src/**/*.test.js`], + tests: [`src/**/*.test.js`], + env: { + kind: `chrome` + } +}) diff --git a/packages/web-microkernel/yarn.lock b/packages/web-microkernel/yarn.lock new file mode 100644 index 00000000000..a4651646950 --- /dev/null +++ b/packages/web-microkernel/yarn.lock @@ -0,0 +1,8 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +lodash-es@4.17.11: + version "4.17.11" + resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" + integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== diff --git a/yarn.lock b/yarn.lock index b454ce5e8cc..696e059bd63 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1419,6 +1419,13 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@nodeguy/channel@0.6.4": + version "0.6.4" + resolved "https://registry.yarnpkg.com/@nodeguy/channel/-/channel-0.6.4.tgz#84d64b513d17c35b599e74e7a16101945f62ae43" + integrity sha512-TT4kEgcj8K9Gj7fzq1m/Nb/xoyTYEc5C27WZPBEebQXQoLeZtoytKi1pYZ6vU3tn/nkj1fkS1sLCJ5w5R/2JpA== + dependencies: + setimmediate "1.0.5" + "@nodelib/fs.stat@^1.0.1": version "1.1.2" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.2.tgz#54c5a964462be3d4d78af631363c18d6fa91ac26" @@ -10122,7 +10129,7 @@ locate-path@^3.0.0: p-locate "^3.0.0" path-exists "^3.0.0" -lodash-es@^4.17.10: +lodash-es@4.17.11, lodash-es@^4.17.10: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash-es/-/lodash-es-4.17.11.tgz#145ab4a7ac5c5e52a3531fb4f310255a152b4be0" integrity sha512-DHb1ub+rMjjrxqlB3H56/6MXtm1lSksDp2rA2cNWjG8mlDUYFhUj3Di2Zn5IwSU87xLv8tNIQ7sSwE/YOX/D/Q== @@ -13874,7 +13881,7 @@ set-value@^2.0.0: is-plain-object "^2.0.3" split-string "^3.0.1" -setimmediate@^1.0.4, setimmediate@^1.0.5: +setimmediate@1.0.5, setimmediate@^1.0.4, setimmediate@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/setimmediate/-/setimmediate-1.0.5.tgz#290cbb232e306942d7d7ea9b83732ab7856f8285" integrity sha1-KQy7Iy4waULX1+qbg3Mqt4VvgoU=