From 0a11d1a8239c0837d67c6eba08886cd72ed9b8df Mon Sep 17 00:00:00 2001 From: Bryan Fox Date: Thu, 26 Jul 2018 18:16:08 -0400 Subject: [PATCH] Refactor to support other workers in the future --- src/containers/Node.js | 8 ++-- src/ducks/modules/__tests__/protocol.test.js | 13 +------ src/ducks/modules/protocol.js | 39 ++++++++++++------- src/selectors/protocol.js | 9 +++++ src/utils/WorkerAgent.js | 12 +++++- src/utils/protocol/__mocks__/index.js | 4 +- src/utils/protocol/index.js | 2 +- .../{loadWorker.js => preloadWorkers.js} | 37 ++++++++++-------- 8 files changed, 72 insertions(+), 52 deletions(-) rename src/utils/protocol/{loadWorker.js => preloadWorkers.js} (66%) diff --git a/src/containers/Node.js b/src/containers/Node.js index 31ac0432b9..743b4f5534 100644 --- a/src/containers/Node.js +++ b/src/containers/Node.js @@ -1,11 +1,11 @@ import React, { PureComponent } from 'react'; import { connect } from 'react-redux'; import PropTypes from 'prop-types'; -import { makeGetNodeColor } from '../selectors/protocol'; -import { Node as UINode } from '../ui/components'; import WorkerAgent from '../utils/WorkerAgent'; +import { Node as UINode } from '../ui/components'; import { getNetwork, getNodeLabelFunction } from '../selectors/interface'; +import { getNodeLabelWorkerUrl, makeGetNodeColor } from '../selectors/protocol'; const getNodeColor = makeGetNodeColor(); @@ -73,8 +73,8 @@ function mapStateToProps(state, props) { return { color: getNodeColor(state, props), getLabel: getNodeLabelFunction(state), - workerUrl: state.protocol.workerUrl, - workerNetwork: (state.protocol.workerUrl && getNetwork(state)) || null, + workerUrl: getNodeLabelWorkerUrl(state), + workerNetwork: (getNodeLabelWorkerUrl(state) && getNetwork(state)) || null, }; } diff --git a/src/ducks/modules/__tests__/protocol.test.js b/src/ducks/modules/__tests__/protocol.test.js index e186ccce57..e2f211a5ea 100644 --- a/src/ducks/modules/__tests__/protocol.test.js +++ b/src/ducks/modules/__tests__/protocol.test.js @@ -3,24 +3,13 @@ import { ActionsObservable } from 'redux-observable'; import { omit } from 'lodash'; -import reducer, { actionCreators, epics } from '../protocol'; +import reducer, { actionCreators, epics, initialState } from '../protocol'; import { actionTypes as SessionActionTypes } from '../session'; import environments from '../../../utils/environments'; import { getEnvironment } from '../../../utils/Environment'; jest.mock('../../../utils/protocol/index'); -const initialState = { - isLoaded: false, - isLoading: false, - error: null, - name: '', - version: '', - required: '', - type: 'factory', - stages: [], -}; - describe('protocol module', () => { describe('reducer', () => { it('should return the initial state', () => { diff --git a/src/ducks/modules/protocol.js b/src/ducks/modules/protocol.js index 1f92cf2967..f5ecf7853a 100644 --- a/src/ducks/modules/protocol.js +++ b/src/ducks/modules/protocol.js @@ -1,7 +1,15 @@ import { combineEpics } from 'redux-observable'; import { Observable } from 'rxjs'; -import { loadWorker, loadProtocol, importProtocol, downloadProtocol, loadFactoryProtocol } from '../../utils/protocol'; import { actionTypes as SessionActionTypes } from './session'; +import { + loadProtocol, + importProtocol, + downloadProtocol, + loadFactoryProtocol, + preloadWorkers, +} from '../../utils/protocol'; + +import { supportedWorkers } from '../../utils/WorkerAgent'; /** * `protocol` maintains information about the currently-loaded protocol for session, and @@ -32,7 +40,7 @@ const LOAD_PROTOCOL_FAILED = Symbol('LOAD_PROTOCOL_FAILED'); const SET_PROTOCOL = 'SET_PROTOCOL'; const SET_WORKER = 'SET_WORKER'; -const initialState = { +export const initialState = { isLoaded: false, isLoading: false, error: null, @@ -41,7 +49,7 @@ const initialState = { required: '', stages: [], type: 'factory', - workerUrl: undefined, + workerUrlMap: null, }; export default function reducer(state = initialState, action = {}) { @@ -57,7 +65,7 @@ export default function reducer(state = initialState, action = {}) { case SET_WORKER: return { ...state, - workerUrl: action.workerUrl, + workerUrlMap: action.workerUrlMap, }; case END_SESSION: return initialState; @@ -148,11 +156,11 @@ function setProtocol(path, protocol, isFactoryProtocol) { }; } -// If there's no custom worker, the set false so we won't expect one later -function setWorkerContent(workerUrl = false) { +// If there's no custom worker, set to empty so we won't expect one later +function setWorkerContent(workerUrlMap = {}) { return { type: SET_WORKER, - workerUrl, + workerUrlMap, }; } @@ -199,13 +207,16 @@ const loadProtocolWorkerEpic = action$ => .ofType(LOAD_PROTOCOL) .switchMap(action => // Favour subsequent load actions over earlier ones Observable - .fromPromise( - loadWorker( - action.path, - 'nodeLabelWorker', - action.protocolType === 'factory', - )) - .map(workerUrl => setWorkerContent(workerUrl)), + .fromPromise(preloadWorkers(action.path, action.protocolType === 'factory')) + .mergeMap(urls => urls) + .reduce((urlMap, workerUrl, i) => { + if (workerUrl) { + // eslint-disable-next-line no-param-reassign + urlMap[supportedWorkers[i]] = workerUrl; + } + return urlMap; + }, {}) + .map(workerUrlMap => setWorkerContent(workerUrlMap)), ); const actionCreators = { diff --git a/src/selectors/protocol.js b/src/selectors/protocol.js index 13e87bcb9b..5d569a159c 100644 --- a/src/selectors/protocol.js +++ b/src/selectors/protocol.js @@ -1,6 +1,8 @@ import crypto from 'crypto'; +import { createSelector } from 'reselect'; import { createDeepEqualSelector } from './utils'; +import { NodeLabelWorkerName } from '../utils/WorkerAgent'; /** * The remote protocol ID on any instance of Server is the hex-encoded sha256 of its [unique] name. @@ -45,3 +47,10 @@ export const makeGetEdgeColor = () => createDeepEqualSelector( return edgeInfo && edgeInfo[edgeType] && edgeInfo[edgeType].color; }, ); + +export const getNodeLabelWorkerUrl = createSelector( + // null if URLs haven't yet loaded; false if worker does not exist + state => state.protocol.workerUrlMap && + (state.protocol.workerUrlMap[NodeLabelWorkerName] || false), + url => url, +); diff --git a/src/utils/WorkerAgent.js b/src/utils/WorkerAgent.js index cd73b0f49b..76a541f21b 100644 --- a/src/utils/WorkerAgent.js +++ b/src/utils/WorkerAgent.js @@ -1,7 +1,15 @@ import uuidv4 from './uuid'; +export const NodeLabelWorkerName = 'nodeLabelWorker'; +export const supportedWorkers = [NodeLabelWorkerName]; + +// Create an object URL from worker contents. +// We own the URL and can release it when no longer needed. +export const urlForWorkerSource = blob => URL.createObjectURL(blob); + const workers = {}; +// Maintain one worker per source URL const getSharedWorker = (url) => { if (!workers[url]) { const worker = new Worker(url); @@ -22,6 +30,7 @@ const getSharedWorker = (url) => { delete worker.workMap[evt.data.messageId]; }; workers[url] = worker; + URL.revokeObjectURL(url); } return workers[url]; }; @@ -32,7 +41,6 @@ const getSharedWorker = (url) => { */ class WorkerAgent { constructor(url) { - this.workerUrl = url; try { this.worker = getSharedWorker(url); } catch (e) { @@ -71,7 +79,7 @@ class WorkerAgent { */ sendMessageAsync(msg) { if (!this.worker) { - return Promise.reject(new Error(`Worker unavailable at ${this.workerUrl}`)); + return Promise.reject(new Error('Worker unavailable')); } const messageId = uuidv4(); const taggedMsg = { ...msg, messageId }; diff --git a/src/utils/protocol/__mocks__/index.js b/src/utils/protocol/__mocks__/index.js index 037dd7b7b1..61257df97e 100644 --- a/src/utils/protocol/__mocks__/index.js +++ b/src/utils/protocol/__mocks__/index.js @@ -1,5 +1,5 @@ const loadProtocol = () => Promise.resolve({ fake: { protocol: { json: true } } }); -const loadWorker = () => Promise.resolve('blob:http://localhost/abc'); +const preloadWorkers = () => Promise.resolve('blob:http://localhost/abc'); const importProtocol = () => Promise.resolve('/app/data/protocol/path'); const downloadProtocol = () => Promise.resolve('/downloaded/protocol/to/temp/path'); const loadFactoryProtocol = () => @@ -7,7 +7,7 @@ const loadFactoryProtocol = () => export { loadProtocol, - loadWorker, + preloadWorkers, importProtocol, downloadProtocol, loadFactoryProtocol, diff --git a/src/utils/protocol/index.js b/src/utils/protocol/index.js index 192d0a8d71..abf023b1f8 100644 --- a/src/utils/protocol/index.js +++ b/src/utils/protocol/index.js @@ -5,6 +5,6 @@ export { default as protocolPath } from './protocolPath'; export { default as factoryProtocolPath } from './factoryProtocolPath'; export { default as loadProtocol } from './loadProtocol'; export { default as loadFactoryProtocol } from './loadFactoryProtocol'; -export { default as loadWorker } from './loadWorker'; export { default as importProtocol } from './importProtocol'; export { default as downloadProtocol } from './downloadProtocol'; +export { default as preloadWorkers } from './preloadWorkers'; diff --git a/src/utils/protocol/loadWorker.js b/src/utils/protocol/preloadWorkers.js similarity index 66% rename from src/utils/protocol/loadWorker.js rename to src/utils/protocol/preloadWorkers.js index ec8fa9205a..01cbd190ee 100644 --- a/src/utils/protocol/loadWorker.js +++ b/src/utils/protocol/preloadWorkers.js @@ -4,7 +4,7 @@ import inEnvironment from '../Environment'; import factoryProtocolPath from './factoryProtocolPath'; import protocolPath from './protocolPath'; -const supportedWorkers = ['nodeLabelWorker']; +import { urlForWorkerSource, supportedWorkers } from '../WorkerAgent'; /** * Builds source code for a Web Worker based on the protocol's @@ -61,25 +61,28 @@ const compileWorker = (src, funcName) => { /* eslint-enable */ }; -const loadWorker = (environment) => { +/** + * preloadWorkers + * @description Read custom worker scripts from the protocol package, if any. + * By preloading any existing, we can bootstrap before protocol.json is parsed. + */ +const preloadWorkers = (environment) => { if (environment !== environments.WEB) { - return (protocolName, workerName, isFactory) => - readFile((isFactory ? factoryProtocolPath : protocolPath)(protocolName, `${workerName}.js`)) - /** - * Load from blob so that script inherits CSP - */ - .then(buf => new TextDecoder().decode(buf)) - .then(str => compileWorker(str, workerName)) - .then(source => new Blob([source], { type: 'text/plain' })) - // FIXME: need to release (revokeObjectURL) (make each consumer responsible - // for releasing after worker created, instead of caching?) - .then(blob => URL.createObjectURL(blob)) - // TODO: could try to use a shared worker... (but see above about URL) - // .then(url => new Worker(url)) - .catch(err => console.warn(err)); // eslint-disable-line no-console + return (protocolName, isFactory) => + Promise.all(supportedWorkers.map(workerName => + readFile((isFactory ? factoryProtocolPath : protocolPath)(protocolName, `${workerName}.js`)) + /** + * Load from blob so that script inherits CSP + */ + .then(buf => new TextDecoder().decode(buf)) + .then(str => compileWorker(str, workerName)) + .then(source => new Blob([source], { type: 'text/plain' })) + .then(blob => urlForWorkerSource(blob)) + .catch(() => {})), + ); } return () => Promise.reject(new Error('loadProtocol() not supported on this platform')); }; -export default inEnvironment(loadWorker); +export default inEnvironment(preloadWorkers);