From a688f03fdb3dd8e56e31b3b298392e52e384d5fc Mon Sep 17 00:00:00 2001 From: Bill Wallace Date: Mon, 8 May 2023 21:18:35 -0400 Subject: [PATCH] feat(App): Loads mode extensions on demand (#3312) * feat: Dynamic plugin imports * Remove unused file * Fix basic dev mode * Fix the modes issue that Joe requested * Merge fixes * Add the ability to publish directory contents * PR review changes --------- --- modes/basic-dev-mode/src/index.js | 16 ++- platform/public/demo.html | 9 ++ .../viewer/.webpack/writePluginImportsFile.js | 101 +++++++++--------- platform/viewer/pluginConfig.json | 79 +++++++++----- platform/viewer/public/config/multiple.js | 2 +- platform/viewer/src/appInit.js | 50 ++++++--- platform/viewer/src/index.js | 35 +++--- platform/viewer/src/routes/Mode/Mode.tsx | 86 ++++++++------- .../viewer/src/routes/WorkList/WorkList.tsx | 2 +- .../viewer/src/routes/buildModeRoutes.tsx | 3 - .../src/routes/checkExtensionDependencies.js | 39 ------- 11 files changed, 225 insertions(+), 197 deletions(-) create mode 100644 platform/public/demo.html delete mode 100644 platform/viewer/src/routes/checkExtensionDependencies.js diff --git a/modes/basic-dev-mode/src/index.js b/modes/basic-dev-mode/src/index.js index 875d26212..0f769854e 100644 --- a/modes/basic-dev-mode/src/index.js +++ b/modes/basic-dev-mode/src/index.js @@ -10,7 +10,6 @@ const configs = { const ohif = { layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', - hangingProtocol: '@ohif/extension-default.hangingProtocolModule.default', measurements: '@ohif/extension-default.panelModule.measure', thumbnailList: '@ohif/extension-default.panelModule.seriesList', }; @@ -47,8 +46,8 @@ const extensionDependencies = { function modeFactory({ modeConfiguration }) { return { id, - routeName: 'viewer', - displayName: 'Basic Viewer CS3D', + routeName: 'dev', + displayName: 'Basic Dev Viewer', /** * Lifecycle hooks */ @@ -134,6 +133,15 @@ function modeFactory({ modeConfiguration }) { 'MoreTools', ]); }, + onModeExit: ({ servicesManager }) => { + const { + toolGroupService, + measurementService, + toolbarService, + } = servicesManager.services; + + toolGroupService.destroy(); + }, validationTags: { study: [], series: [], @@ -177,7 +185,7 @@ function modeFactory({ modeConfiguration }) { }, ], extensions: extensionDependencies, - hangingProtocol: [ohif.hangingProtocol], + hangingProtocol: 'default', sopClassHandlers: [ dicomvideo.sopClassHandler, ohif.sopClassHandler, diff --git a/platform/public/demo.html b/platform/public/demo.html new file mode 100644 index 000000000..7b3a647ed --- /dev/null +++ b/platform/public/demo.html @@ -0,0 +1,9 @@ + + +

Demo Page

+

This demo page shows how to include directories of content in the pluginConfig.json file. + The idea is that it is possible to serve up non-compiled content such as the microscopy mode + which will not be included in the compile of OHIF but can be included later on dynamically. +

+ + diff --git a/platform/viewer/.webpack/writePluginImportsFile.js b/platform/viewer/.webpack/writePluginImportsFile.js index d3d2667fd..41caf84b6 100644 --- a/platform/viewer/.webpack/writePluginImportsFile.js +++ b/platform/viewer/.webpack/writePluginImportsFile.js @@ -1,11 +1,14 @@ const pluginConfig = require('../pluginConfig.json'); const fs = require('fs'); +const os = require('os'); const glob = require('glob'); const autogenerationDisclaimer = ` // THIS FILE IS AUTOGENERATED AS PART OF THE EXTENSION AND MODE PLUGIN PROCESS. // IT SHOULD NOT BE MODIFIED MANUALLY \n`; +const extractName = (val) => (typeof val === 'string') ? val : val.packageName; + function constructLines(input, categoryName) { let pluginCount = 0; @@ -19,14 +22,10 @@ function constructLines(input, categoryName) { input.forEach(entry => { if (entry.default === false) return; - const packageName = entry.packageName; - const defaultImportName = `${categoryName}${pluginCount}`; + const packageName = extractName(entry); - lines.importLines.push( - `import ${defaultImportName} from '${packageName}';\n` - ); lines.addToWindowLines.push( - `${categoryName}.push(${defaultImportName});\n` + `${categoryName}.push("${packageName}");\n` ); pluginCount++; @@ -49,7 +48,7 @@ function getFormattedWindowBlock(addToWindowLines) { let content = 'const extensions = [];\n' + 'const modes = [];\n' + - 'const modesFactory = [];\n' + + '\n// Not required any longer\n' + 'window.extensions = extensions;\n' + 'window.modes = modes;\n\n'; @@ -60,47 +59,55 @@ function getFormattedWindowBlock(addToWindowLines) { return content; } -function getRuntimeLoadModesExtensions() { - return ( - '\n\n// Add a dynamic runtime loader\n' + - 'export default async () => {\n' + - ' for(const modeFactory of modesFactory) {\n' + - ' const newModes = await modeFactory(modes,extensions);\n' + - ' newModes.forEach(newMode => modes.push(newMode));\n' + - '}\n}\n\n\n' + - 'async function loadRuntimeImports(config) {\n' + - ' if (config && config.modes && config.modes.length) {\n' + - ' for (const modeName of config.modes) {\n' + - ' const existingMode = modes.find(mode => mode.id === modeName);\n' + - ' if (!existingMode) {\n' + - ' if (modeName === `@ohif/mode-test`) {\n' + - ' const mode = await import(`@ohif/mode-test`);\n' + - ' window.modes.push(mode.default);\n' + - ' const extension = await import(`@ohif/extension-test`);\n' + - ' window.extensions.push(extension.default);\n' + - ' }\n' + - ' }\n' + - ' };\n' + - ' }\n' + - '}\n' + - 'export { loadRuntimeImports };\n\n' - ); +function getRuntimeLoadModesExtensions(modules) { + const dynamicLoad = []; + dynamicLoad.push( + '\n\n// Add a dynamic runtime loader', + 'async function loadModule(module) {', + ' if (typeof module !== \'string\') return module;'); + modules.forEach(module => { + const packageName = extractName(module); + dynamicLoad.push( + ` if( module==="${packageName}") {`, + ` const imported = await import("${packageName}");`, + ' return imported.default;', + ' }' + ); + }) + dynamicLoad.push( + ' return (await import(module)).default;', + '}\n', + '// Import a list of items (modules or string names)', + '// @return a Promise evaluating to a list of modules', + 'export default function importItems(modules) {', + ' return Promise.all(modules.map(loadModule));', + '}\n', + 'export { loadModule, modes, extensions, importItems };\n\n'); + return dynamicLoad.join('\n'); +} + +const fromDirectory = (srcDir, path) => { + if (!path) return; + if (path[0] === '.') return srcDir + '/../../..' + path.substring(1); + if (path[0] === '~') return os.homedir() + path.substring(1); + return path; } const createCopyPluginToDistForLink = ( - SRC_DIR, - DIST_DIR, + srcDir, + distDir, plugins, folderName ) => { return plugins .map(plugin => { - const from = `${SRC_DIR}/../node_modules/${plugin.packageName}/${folderName}/`; + const fromDir = fromDirectory(srcDir, plugin.directory); + const from = fromDir || `${srcDir}/../node_modules/${plugin.packageName}/${folderName}/`; const exists = fs.existsSync(from); return exists ? { from, - to: DIST_DIR, + to: distDir, toType: 'dir', } : undefined; @@ -134,23 +141,20 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) { const extensionLines = constructLines(pluginConfig.extensions, 'extensions'); const modeLines = constructLines(pluginConfig.modes, 'modes'); - const modesFactoryLines = constructLines( - pluginConfig.modesFactory, - 'modesFactory' - ); pluginImportsJsContent += getFormattedImportBlock([ ...extensionLines.importLines, ...modeLines.importLines, - ...modesFactoryLines.importLines, ]); pluginImportsJsContent += getFormattedWindowBlock([ ...extensionLines.addToWindowLines, ...modeLines.addToWindowLines, - ...modesFactoryLines.addToWindowLines, ]); - pluginImportsJsContent += getRuntimeLoadModesExtensions(); + pluginImportsJsContent += getRuntimeLoadModesExtensions([ + ...pluginConfig.extensions, + ...pluginConfig.modes, + ]); fs.writeFileSync( `${SRC_DIR}/pluginImports.js`, @@ -172,10 +176,8 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) { SRC_DIR, DIST_DIR, [ - ...pluginConfig.modesFactory, ...pluginConfig.modes, ...pluginConfig.extensions, - ...pluginConfig.umd, ], 'public' ); @@ -184,10 +186,9 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) { SRC_DIR, DIST_DIR, [ - ...pluginConfig.modesFactory, ...pluginConfig.modes, ...pluginConfig.extensions, - ...pluginConfig.umd, + ...pluginConfig.public, ], 'public' ); @@ -198,10 +199,8 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) { SRC_DIR, DIST_DIR, [ - ...pluginConfig.modesFactory, ...pluginConfig.modes, ...pluginConfig.extensions, - ...pluginConfig.umd, ], 'dist' ); @@ -210,15 +209,13 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) { SRC_DIR, DIST_DIR, [ - ...pluginConfig.modesFactory, ...pluginConfig.modes, ...pluginConfig.extensions, - ...pluginConfig.umd, ], 'dist' ); - console.log('copy plugins', [ + console.warn('copy plugins', [ ...copyPluginPublicToDistBuild, ...copyPluginPublicToDistLink, ...copyPluginDistToDistBuild, diff --git a/platform/viewer/pluginConfig.json b/platform/viewer/pluginConfig.json index 37e90e765..ec3743c95 100644 --- a/platform/viewer/pluginConfig.json +++ b/platform/viewer/pluginConfig.json @@ -1,60 +1,83 @@ { "extensions": [ + { + "packageName": "@ohif/extension-default" + }, { "packageName": "@ohif/extension-cornerstone", + "default": false, "version": "3.0.0" }, { "packageName": "@ohif/extension-measurement-tracking", + "default": false, "version": "3.0.0" }, { "packageName": "@ohif/extension-cornerstone-dicom-sr", - "version": "3.0.0" - }, - { - "packageName": "@ohif/extension-default", - "version": "3.0.0" - }, - { - "packageName": "@ohif/extension-dicom-microscopy", - "version": "3.0.0" - }, - { - "packageName": "@ohif/extension-dicom-pdf", - "version": "3.0.1" - }, - { - "packageName": "@ohif/extension-dicom-video", - "version": "3.0.1" - }, - { - "packageName": "@ohif/extension-tmtv", + "default": false, "version": "3.0.0" }, { "packageName": "@ohif/extension-cornerstone-dicom-seg", + "default": false, + "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-dicom-microscopy", + "default": false, + "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-dicom-pdf", + "default": false, + "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-dicom-video", + "default": false, + "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-tmtv", + "default": false, + "version": "3.0.0" + }, + { + "packageName": "@ohif/extension-test", + "default": false, "version": "3.0.0" }, { "packageName": "@ohif/extension-cornerstone-dicom-rt", + "default": false, "version": "3.0.0" } ], "modes": [ { - "packageName": "@ohif/mode-longitudinal", + "packageName": "@ohif/mode-longitudinal" + }, + { + "packageName": "@ohif/mode-tmtv" + }, + { + "packageName": "@ohif/mode-microscopy" + }, + { + "packageName": "@ohif/mode-test", + "default": false, "version": "3.0.0" }, { - "packageName": "@ohif/mode-microscopy", - "version": "3.0.0" - }, - { - "packageName": "@ohif/mode-tmtv", + "packageName": "@ohif/mode-basic-dev-mode", + "default": false, "version": "3.0.0" } ], - "modesFactory": [], - "umd": [] + "public": [ + { + "directory": "./platform/public" + } + ] } diff --git a/platform/viewer/public/config/multiple.js b/platform/viewer/public/config/multiple.js index a5de22d0d..d0e726caf 100644 --- a/platform/viewer/public/config/multiple.js +++ b/platform/viewer/public/config/multiple.js @@ -12,7 +12,7 @@ window.config = { }, ], extensions: [], - modes: ['@ohif/mode-test'], + modes: ['@ohif/mode-test', '@ohif/mode-basic-dev-mode'], showStudyList: true, maxNumberOfWebWorkers: 4, // below flag is for performance reasons, but it might not work for all servers diff --git a/platform/viewer/src/appInit.js b/platform/viewer/src/appInit.js index 58908c374..c6ddbf7ac 100644 --- a/platform/viewer/src/appInit.js +++ b/platform/viewer/src/appInit.js @@ -21,6 +21,8 @@ import { // utils, } from '@ohif/core'; +import loadModules from './pluginImports'; + /** * @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration * @param {object[]} defaultExtensions - array of extension objects @@ -74,8 +76,12 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { * Example: [ext1, ext2, ext3] * Example2: [[ext1, config], ext2, [ext3, config]] */ + const loadedExtensions = await loadModules([ + ...defaultExtensions, + ...appConfig.extensions, + ]); await extensionManager.registerExtensions( - [...defaultExtensions, ...appConfig.extensions], + loadedExtensions, appConfig.dataSources ); @@ -87,24 +93,38 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) { throw new Error('No modes are defined! Check your app-config.js'); } - for (let i = 0; i < defaultModes.length; i++) { - const { modeFactory, id } = defaultModes[i]; + const loadedModes = await loadModules([ + ...(appConfig.modes || []), + ...defaultModes, + ]); - // If the appConfig contains configuration for this mode, use it. - const modeConfig = - appConfig.modeConfig && appConfig.modeConfig[i] - ? appConfig.modeConfig[id] - : {}; + // This is the name for the loaded istance object + appConfig.loadedModes = []; + const modesById = new Set(); + for (let i = 0; i < loadedModes.length; i++) { + let mode = loadedModes[i]; + if (!mode) continue; + const { id } = mode; - const mode = modeFactory(modeConfig); + if (mode.modeFactory) { + // If the appConfig contains configuration for this mode, use it. + const modeConfig = + appConfig.modeConfig && appConfig.modeConfig[i] + ? appConfig.modeConfig[id] + : {}; - appConfig.modes.push(mode); + mode = mode.modeFactory(modeConfig); + } + + if (modesById.has(id)) continue; + // Prevent duplication + modesById.add(id); + if (!mode || typeof mode !== 'object') continue; + appConfig.loadedModes.push(mode); } - - // remove modes that are not objects, or have no id - appConfig.modes = appConfig.modes.filter( - mode => typeof mode === 'object' && mode.id - ); + // Hack alert - don't touch the original modes definition, + // but there are still dependencies on having the appConfig modes defined + appConfig.modes = appConfig.loadedModes; return { appConfig, diff --git a/platform/viewer/src/index.js b/platform/viewer/src/index.js index 6cec78ad2..71d48e2ef 100644 --- a/platform/viewer/src/index.js +++ b/platform/viewer/src/index.js @@ -16,25 +16,24 @@ import { history } from './utils/history'; * pluginImports.js imports all of the modes and extensions and adds them * to the window for processing. */ -import loadDynamicImports, { loadRuntimeImports } from './pluginImports.js'; +import { + modes as defaultModes, + extensions as defaultExtensions, +} from './pluginImports'; -loadDynamicImports().then(() => { - loadRuntimeImports(window.config).then(() => { - /** - * Combine our appConfiguration with installed extensions and modes. - * In the future appConfiguration may contain modes added at runtime. - * */ - const appProps = { - config: window ? window.config : {}, - defaultExtensions: window.extensions, - defaultModes: window.modes, - }; +/** + * Combine our appConfiguration with installed extensions and modes. + * In the future appConfiguration may contain modes added at runtime. + * */ +const appProps = { + config: window ? window.config : {}, + defaultExtensions, + defaultModes, +}; - /** Create App */ - const app = React.createElement(App, appProps, null); - /** Render */ - ReactDOM.render(app, document.getElementById('root')); - }); -}); +/** Create App */ +const app = React.createElement(App, appProps, null); +/** Render */ +ReactDOM.render(app, document.getElementById('root')); export { history }; diff --git a/platform/viewer/src/routes/Mode/Mode.tsx b/platform/viewer/src/routes/Mode/Mode.tsx index 7e7575bc0..9caf969a0 100644 --- a/platform/viewer/src/routes/Mode/Mode.tsx +++ b/platform/viewer/src/routes/Mode/Mode.tsx @@ -9,6 +9,7 @@ import ViewportGrid from '@components/ViewportGrid'; import Compose from './Compose'; import getStudies from './studiesList'; import { history } from '../../utils/history'; +import loadModules from '../../pluginImports'; const { getSplitParam } = utils; @@ -102,6 +103,8 @@ export default function ModeRoute({ const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(); const [refresh, setRefresh] = useState(false); + const [allExtensionsLoaded, setAllExtensionsLoaded] = useState(false); + const layoutTemplateData = useRef(false); const locationRef = useRef(null); const isMounted = useRef(false); @@ -235,41 +238,52 @@ export default function ModeRoute({ return; } - // TODO: For some reason this is running before the Providers - // are calling setServiceImplementation - // TODO -> iterate through services. - - // Extension - - // Add SOPClassHandlers to a new SOPClassManager. - displaySetService.init(extensionManager, sopClassHandlers); - - extensionManager.onModeEnter({ - servicesManager, - extensionManager, - commandsManager, - }); - - // use the URL hangingProtocolId if it exists, otherwise use the one - // defined in the mode configuration - const hangingProtocolIdToUse = hangingProtocolService.getProtocolById( - runTimeHangingProtocolId - ) - ? runTimeHangingProtocolId - : hangingProtocol; - - // Sets the active hanging protocols - if hangingProtocol is undefined, - // resets to default. Done before the onModeEnter to allow the onModeEnter - // to perform custom hanging protocol actions - hangingProtocolService.setActiveProtocolIds(hangingProtocolIdToUse); - - mode?.onModeEnter({ - servicesManager, - extensionManager, - commandsManager, - }); - const setupRouteInit = async () => { + const loadedExtensions = await loadModules(Object.keys(extensions)); + for (const extension of loadedExtensions) { + const { id: extensionId } = extension; + if ( + extensionManager.registeredExtensionIds.indexOf(extensionId) === -1 + ) { + await extensionManager.registerExtension(extension); + } + } + setAllExtensionsLoaded(true); + + // TODO: For some reason this is running before the Providers + // are calling setServiceImplementation + // TODO -> iterate through services. + + // Extension + + // Add SOPClassHandlers to a new SOPClassManager. + displaySetService.init(extensionManager, sopClassHandlers); + + extensionManager.onModeEnter({ + servicesManager, + extensionManager, + commandsManager, + }); + + // use the URL hangingProtocolId if it exists, otherwise use the one + // defined in the mode configuration + const hangingProtocolIdToUse = hangingProtocolService.getProtocolById( + runTimeHangingProtocolId + ) + ? runTimeHangingProtocolId + : hangingProtocol; + + // Sets the active hanging protocols - if hangingProtocol is undefined, + // resets to default. Done before the onModeEnter to allow the onModeEnter + // to perform custom hanging protocol actions + hangingProtocolService.setActiveProtocolIds(hangingProtocolIdToUse); + + mode?.onModeEnter({ + servicesManager, + extensionManager, + commandsManager, + }); + /** * The next line should get all the query parameters provided by the URL * - except the StudyInstanceUIDs - and create an object called filters @@ -382,8 +396,8 @@ export default function ModeRoute({ {layoutTemplateData.current && - studyInstanceUIDs?.length && - studyInstanceUIDs[0] !== undefined && + studyInstanceUIDs?.[0] !== undefined && + allExtensionsLoaded && renderLayoutData({ ...layoutTemplateData.current.props, ViewportGridComp: ViewportGridWithDataSource, diff --git a/platform/viewer/src/routes/WorkList/WorkList.tsx b/platform/viewer/src/routes/WorkList/WorkList.tsx index 52d8928ca..f1987751c 100644 --- a/platform/viewer/src/routes/WorkList/WorkList.tsx +++ b/platform/viewer/src/routes/WorkList/WorkList.tsx @@ -336,7 +336,7 @@ function WorkList({ : [] } > - {appConfig.modes.map((mode, i) => { + {appConfig.loadedModes.map((mode, i) => { const isFirst = i === 0; const isValidMode = mode.isValidMode({ modalities }); diff --git a/platform/viewer/src/routes/buildModeRoutes.tsx b/platform/viewer/src/routes/buildModeRoutes.tsx index e87eb186e..c3375788d 100644 --- a/platform/viewer/src/routes/buildModeRoutes.tsx +++ b/platform/viewer/src/routes/buildModeRoutes.tsx @@ -1,6 +1,5 @@ import React from 'react'; import ModeRoute from '@routes/Mode'; -import checkExtensionDependencies from './checkExtensionDependencies'; /* Routes uniquely define an entry point to: @@ -41,8 +40,6 @@ export default function buildModeRoutes({ }); modes.forEach(mode => { - checkExtensionDependencies(mode, extensionManager); - // todo: for each route. add route to path. dataSourceNames.forEach(dataSourceName => { const path = `/${mode.routeName}/${dataSourceName}`; diff --git a/platform/viewer/src/routes/checkExtensionDependencies.js b/platform/viewer/src/routes/checkExtensionDependencies.js deleted file mode 100644 index 7fb8f61a7..000000000 --- a/platform/viewer/src/routes/checkExtensionDependencies.js +++ /dev/null @@ -1,39 +0,0 @@ -export default function checkExtensionDependencies(mode, extensionManager) { - const extensionDependencies = mode.extensions; - - const dependencyString = `Unmet extension dependency in mode: ${mode.id}`; - - Object.keys(extensionDependencies).forEach(extensionId => { - const extensionInstalled = extensionManager.registeredExtensionIds.includes( - extensionId - ); - - if (!extensionInstalled) { - throw new Error( - `${dependencyString}: extension ${extensionId} not found` - ); - } - }); -} - -function areVersionsCompatible(semanticVersion, installedVersion) { - if (semanticVersion.includes('^')) { - // Major must match - const versionLessCaret = semanticVersion.split('^')[1]; - - // Index 0 is the major version. - return versionLessCaret[0] === installedVersion[0]; - } else if (semanticVersion.includes('~')) { - // Major and minor must match - const versionLessTilde = semanticVersion.split('~')[1]; - - // Index 0 is the major version. - // Index 2 is the minor version. - return ( - versionLessTilde[0] === installedVersion[0] && - versionLessTilde[2] === installedVersion[2] - ); - } else { - return semanticVersion === installedVersion; - } -}