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

---------
This commit is contained in:
Bill Wallace 2023-05-08 21:18:35 -04:00 committed by GitHub
parent 2f355fa158
commit a688f03fdb
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 225 additions and 197 deletions

View File

@ -10,7 +10,6 @@ const configs = {
const ohif = { const ohif = {
layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout', layout: '@ohif/extension-default.layoutTemplateModule.viewerLayout',
sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack', sopClassHandler: '@ohif/extension-default.sopClassHandlerModule.stack',
hangingProtocol: '@ohif/extension-default.hangingProtocolModule.default',
measurements: '@ohif/extension-default.panelModule.measure', measurements: '@ohif/extension-default.panelModule.measure',
thumbnailList: '@ohif/extension-default.panelModule.seriesList', thumbnailList: '@ohif/extension-default.panelModule.seriesList',
}; };
@ -47,8 +46,8 @@ const extensionDependencies = {
function modeFactory({ modeConfiguration }) { function modeFactory({ modeConfiguration }) {
return { return {
id, id,
routeName: 'viewer', routeName: 'dev',
displayName: 'Basic Viewer CS3D', displayName: 'Basic Dev Viewer',
/** /**
* Lifecycle hooks * Lifecycle hooks
*/ */
@ -134,6 +133,15 @@ function modeFactory({ modeConfiguration }) {
'MoreTools', 'MoreTools',
]); ]);
}, },
onModeExit: ({ servicesManager }) => {
const {
toolGroupService,
measurementService,
toolbarService,
} = servicesManager.services;
toolGroupService.destroy();
},
validationTags: { validationTags: {
study: [], study: [],
series: [], series: [],
@ -177,7 +185,7 @@ function modeFactory({ modeConfiguration }) {
}, },
], ],
extensions: extensionDependencies, extensions: extensionDependencies,
hangingProtocol: [ohif.hangingProtocol], hangingProtocol: 'default',
sopClassHandlers: [ sopClassHandlers: [
dicomvideo.sopClassHandler, dicomvideo.sopClassHandler,
ohif.sopClassHandler, ohif.sopClassHandler,

View File

@ -0,0 +1,9 @@
<html>
<body>
<h1>Demo Page</h1>
<p>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.
</p>
</body>
</html>

View File

@ -1,11 +1,14 @@
const pluginConfig = require('../pluginConfig.json'); const pluginConfig = require('../pluginConfig.json');
const fs = require('fs'); const fs = require('fs');
const os = require('os');
const glob = require('glob'); const glob = require('glob');
const autogenerationDisclaimer = ` const autogenerationDisclaimer = `
// THIS FILE IS AUTOGENERATED AS PART OF THE EXTENSION AND MODE PLUGIN PROCESS. // THIS FILE IS AUTOGENERATED AS PART OF THE EXTENSION AND MODE PLUGIN PROCESS.
// IT SHOULD NOT BE MODIFIED MANUALLY \n`; // IT SHOULD NOT BE MODIFIED MANUALLY \n`;
const extractName = (val) => (typeof val === 'string') ? val : val.packageName;
function constructLines(input, categoryName) { function constructLines(input, categoryName) {
let pluginCount = 0; let pluginCount = 0;
@ -19,14 +22,10 @@ function constructLines(input, categoryName) {
input.forEach(entry => { input.forEach(entry => {
if (entry.default === false) return; if (entry.default === false) return;
const packageName = entry.packageName; const packageName = extractName(entry);
const defaultImportName = `${categoryName}${pluginCount}`;
lines.importLines.push(
`import ${defaultImportName} from '${packageName}';\n`
);
lines.addToWindowLines.push( lines.addToWindowLines.push(
`${categoryName}.push(${defaultImportName});\n` `${categoryName}.push("${packageName}");\n`
); );
pluginCount++; pluginCount++;
@ -49,7 +48,7 @@ function getFormattedWindowBlock(addToWindowLines) {
let content = let content =
'const extensions = [];\n' + 'const extensions = [];\n' +
'const modes = [];\n' + 'const modes = [];\n' +
'const modesFactory = [];\n' + '\n// Not required any longer\n' +
'window.extensions = extensions;\n' + 'window.extensions = extensions;\n' +
'window.modes = modes;\n\n'; 'window.modes = modes;\n\n';
@ -60,47 +59,55 @@ function getFormattedWindowBlock(addToWindowLines) {
return content; return content;
} }
function getRuntimeLoadModesExtensions() { function getRuntimeLoadModesExtensions(modules) {
return ( const dynamicLoad = [];
'\n\n// Add a dynamic runtime loader\n' + dynamicLoad.push(
'export default async () => {\n' + '\n\n// Add a dynamic runtime loader',
' for(const modeFactory of modesFactory) {\n' + 'async function loadModule(module) {',
' const newModes = await modeFactory(modes,extensions);\n' + ' if (typeof module !== \'string\') return module;');
' newModes.forEach(newMode => modes.push(newMode));\n' + modules.forEach(module => {
'}\n}\n\n\n' + const packageName = extractName(module);
'async function loadRuntimeImports(config) {\n' + dynamicLoad.push(
' if (config && config.modes && config.modes.length) {\n' + ` if( module==="${packageName}") {`,
' for (const modeName of config.modes) {\n' + ` const imported = await import("${packageName}");`,
' const existingMode = modes.find(mode => mode.id === modeName);\n' + ' return imported.default;',
' if (!existingMode) {\n' + ' }'
' if (modeName === `@ohif/mode-test`) {\n' + );
' const mode = await import(`@ohif/mode-test`);\n' + })
' window.modes.push(mode.default);\n' + dynamicLoad.push(
' const extension = await import(`@ohif/extension-test`);\n' + ' return (await import(module)).default;',
' window.extensions.push(extension.default);\n' + '}\n',
' }\n' + '// Import a list of items (modules or string names)',
' }\n' + '// @return a Promise evaluating to a list of modules',
' };\n' + 'export default function importItems(modules) {',
' }\n' + ' return Promise.all(modules.map(loadModule));',
'}\n' + '}\n',
'export { loadRuntimeImports };\n\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 = ( const createCopyPluginToDistForLink = (
SRC_DIR, srcDir,
DIST_DIR, distDir,
plugins, plugins,
folderName folderName
) => { ) => {
return plugins return plugins
.map(plugin => { .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); const exists = fs.existsSync(from);
return exists return exists
? { ? {
from, from,
to: DIST_DIR, to: distDir,
toType: 'dir', toType: 'dir',
} }
: undefined; : undefined;
@ -134,23 +141,20 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) {
const extensionLines = constructLines(pluginConfig.extensions, 'extensions'); const extensionLines = constructLines(pluginConfig.extensions, 'extensions');
const modeLines = constructLines(pluginConfig.modes, 'modes'); const modeLines = constructLines(pluginConfig.modes, 'modes');
const modesFactoryLines = constructLines(
pluginConfig.modesFactory,
'modesFactory'
);
pluginImportsJsContent += getFormattedImportBlock([ pluginImportsJsContent += getFormattedImportBlock([
...extensionLines.importLines, ...extensionLines.importLines,
...modeLines.importLines, ...modeLines.importLines,
...modesFactoryLines.importLines,
]); ]);
pluginImportsJsContent += getFormattedWindowBlock([ pluginImportsJsContent += getFormattedWindowBlock([
...extensionLines.addToWindowLines, ...extensionLines.addToWindowLines,
...modeLines.addToWindowLines, ...modeLines.addToWindowLines,
...modesFactoryLines.addToWindowLines,
]); ]);
pluginImportsJsContent += getRuntimeLoadModesExtensions(); pluginImportsJsContent += getRuntimeLoadModesExtensions([
...pluginConfig.extensions,
...pluginConfig.modes,
]);
fs.writeFileSync( fs.writeFileSync(
`${SRC_DIR}/pluginImports.js`, `${SRC_DIR}/pluginImports.js`,
@ -172,10 +176,8 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) {
SRC_DIR, SRC_DIR,
DIST_DIR, DIST_DIR,
[ [
...pluginConfig.modesFactory,
...pluginConfig.modes, ...pluginConfig.modes,
...pluginConfig.extensions, ...pluginConfig.extensions,
...pluginConfig.umd,
], ],
'public' 'public'
); );
@ -184,10 +186,9 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) {
SRC_DIR, SRC_DIR,
DIST_DIR, DIST_DIR,
[ [
...pluginConfig.modesFactory,
...pluginConfig.modes, ...pluginConfig.modes,
...pluginConfig.extensions, ...pluginConfig.extensions,
...pluginConfig.umd, ...pluginConfig.public,
], ],
'public' 'public'
); );
@ -198,10 +199,8 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) {
SRC_DIR, SRC_DIR,
DIST_DIR, DIST_DIR,
[ [
...pluginConfig.modesFactory,
...pluginConfig.modes, ...pluginConfig.modes,
...pluginConfig.extensions, ...pluginConfig.extensions,
...pluginConfig.umd,
], ],
'dist' 'dist'
); );
@ -210,15 +209,13 @@ function writePluginImportsFile(SRC_DIR, DIST_DIR) {
SRC_DIR, SRC_DIR,
DIST_DIR, DIST_DIR,
[ [
...pluginConfig.modesFactory,
...pluginConfig.modes, ...pluginConfig.modes,
...pluginConfig.extensions, ...pluginConfig.extensions,
...pluginConfig.umd,
], ],
'dist' 'dist'
); );
console.log('copy plugins', [ console.warn('copy plugins', [
...copyPluginPublicToDistBuild, ...copyPluginPublicToDistBuild,
...copyPluginPublicToDistLink, ...copyPluginPublicToDistLink,
...copyPluginDistToDistBuild, ...copyPluginDistToDistBuild,

View File

@ -1,60 +1,83 @@
{ {
"extensions": [ "extensions": [
{
"packageName": "@ohif/extension-default"
},
{ {
"packageName": "@ohif/extension-cornerstone", "packageName": "@ohif/extension-cornerstone",
"default": false,
"version": "3.0.0" "version": "3.0.0"
}, },
{ {
"packageName": "@ohif/extension-measurement-tracking", "packageName": "@ohif/extension-measurement-tracking",
"default": false,
"version": "3.0.0" "version": "3.0.0"
}, },
{ {
"packageName": "@ohif/extension-cornerstone-dicom-sr", "packageName": "@ohif/extension-cornerstone-dicom-sr",
"version": "3.0.0" "default": false,
},
{
"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",
"version": "3.0.0" "version": "3.0.0"
}, },
{ {
"packageName": "@ohif/extension-cornerstone-dicom-seg", "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" "version": "3.0.0"
}, },
{ {
"packageName": "@ohif/extension-cornerstone-dicom-rt", "packageName": "@ohif/extension-cornerstone-dicom-rt",
"default": false,
"version": "3.0.0" "version": "3.0.0"
} }
], ],
"modes": [ "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" "version": "3.0.0"
}, },
{ {
"packageName": "@ohif/mode-microscopy", "packageName": "@ohif/mode-basic-dev-mode",
"version": "3.0.0" "default": false,
},
{
"packageName": "@ohif/mode-tmtv",
"version": "3.0.0" "version": "3.0.0"
} }
], ],
"modesFactory": [], "public": [
"umd": [] {
"directory": "./platform/public"
}
]
} }

View File

@ -12,7 +12,7 @@ window.config = {
}, },
], ],
extensions: [], extensions: [],
modes: ['@ohif/mode-test'], modes: ['@ohif/mode-test', '@ohif/mode-basic-dev-mode'],
showStudyList: true, showStudyList: true,
maxNumberOfWebWorkers: 4, maxNumberOfWebWorkers: 4,
// below flag is for performance reasons, but it might not work for all servers // below flag is for performance reasons, but it might not work for all servers

View File

@ -21,6 +21,8 @@ import {
// utils, // utils,
} from '@ohif/core'; } from '@ohif/core';
import loadModules from './pluginImports';
/** /**
* @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration * @param {object|func} appConfigOrFunc - application configuration, or a function that returns application configuration
* @param {object[]} defaultExtensions - array of extension objects * @param {object[]} defaultExtensions - array of extension objects
@ -74,8 +76,12 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
* Example: [ext1, ext2, ext3] * Example: [ext1, ext2, ext3]
* Example2: [[ext1, config], ext2, [ext3, config]] * Example2: [[ext1, config], ext2, [ext3, config]]
*/ */
const loadedExtensions = await loadModules([
...defaultExtensions,
...appConfig.extensions,
]);
await extensionManager.registerExtensions( await extensionManager.registerExtensions(
[...defaultExtensions, ...appConfig.extensions], loadedExtensions,
appConfig.dataSources appConfig.dataSources
); );
@ -87,24 +93,38 @@ async function appInit(appConfigOrFunc, defaultExtensions, defaultModes) {
throw new Error('No modes are defined! Check your app-config.js'); throw new Error('No modes are defined! Check your app-config.js');
} }
for (let i = 0; i < defaultModes.length; i++) { const loadedModes = await loadModules([
const { modeFactory, id } = defaultModes[i]; ...(appConfig.modes || []),
...defaultModes,
]);
// If the appConfig contains configuration for this mode, use it. // This is the name for the loaded istance object
const modeConfig = appConfig.loadedModes = [];
appConfig.modeConfig && appConfig.modeConfig[i] const modesById = new Set();
? appConfig.modeConfig[id] 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);
} }
// Hack alert - don't touch the original modes definition,
// remove modes that are not objects, or have no id // but there are still dependencies on having the appConfig modes defined
appConfig.modes = appConfig.modes.filter( appConfig.modes = appConfig.loadedModes;
mode => typeof mode === 'object' && mode.id
);
return { return {
appConfig, appConfig,

View File

@ -16,25 +16,24 @@ import { history } from './utils/history';
* pluginImports.js imports all of the modes and extensions and adds them * pluginImports.js imports all of the modes and extensions and adds them
* to the window for processing. * 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.
* Combine our appConfiguration with installed extensions and modes. * */
* In the future appConfiguration may contain modes added at runtime. const appProps = {
* */ config: window ? window.config : {},
const appProps = { defaultExtensions,
config: window ? window.config : {}, defaultModes,
defaultExtensions: window.extensions, };
defaultModes: window.modes,
};
/** Create App */ /** Create App */
const app = React.createElement(App, appProps, null); const app = React.createElement(App, appProps, null);
/** Render */ /** Render */
ReactDOM.render(app, document.getElementById('root')); ReactDOM.render(app, document.getElementById('root'));
});
});
export { history }; export { history };

View File

@ -9,6 +9,7 @@ import ViewportGrid from '@components/ViewportGrid';
import Compose from './Compose'; import Compose from './Compose';
import getStudies from './studiesList'; import getStudies from './studiesList';
import { history } from '../../utils/history'; import { history } from '../../utils/history';
import loadModules from '../../pluginImports';
const { getSplitParam } = utils; const { getSplitParam } = utils;
@ -102,6 +103,8 @@ export default function ModeRoute({
const [studyInstanceUIDs, setStudyInstanceUIDs] = useState(); const [studyInstanceUIDs, setStudyInstanceUIDs] = useState();
const [refresh, setRefresh] = useState(false); const [refresh, setRefresh] = useState(false);
const [allExtensionsLoaded, setAllExtensionsLoaded] = useState(false);
const layoutTemplateData = useRef(false); const layoutTemplateData = useRef(false);
const locationRef = useRef(null); const locationRef = useRef(null);
const isMounted = useRef(false); const isMounted = useRef(false);
@ -235,41 +238,52 @@ export default function ModeRoute({
return; 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 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 * The next line should get all the query parameters provided by the URL
* - except the StudyInstanceUIDs - and create an object called filters * - except the StudyInstanceUIDs - and create an object called filters
@ -382,8 +396,8 @@ export default function ModeRoute({
<CombinedContextProvider> <CombinedContextProvider>
<DragAndDropProvider> <DragAndDropProvider>
{layoutTemplateData.current && {layoutTemplateData.current &&
studyInstanceUIDs?.length && studyInstanceUIDs?.[0] !== undefined &&
studyInstanceUIDs[0] !== undefined && allExtensionsLoaded &&
renderLayoutData({ renderLayoutData({
...layoutTemplateData.current.props, ...layoutTemplateData.current.props,
ViewportGridComp: ViewportGridWithDataSource, ViewportGridComp: ViewportGridWithDataSource,

View File

@ -336,7 +336,7 @@ function WorkList({
: [] : []
} }
> >
{appConfig.modes.map((mode, i) => { {appConfig.loadedModes.map((mode, i) => {
const isFirst = i === 0; const isFirst = i === 0;
const isValidMode = mode.isValidMode({ modalities }); const isValidMode = mode.isValidMode({ modalities });

View File

@ -1,6 +1,5 @@
import React from 'react'; import React from 'react';
import ModeRoute from '@routes/Mode'; import ModeRoute from '@routes/Mode';
import checkExtensionDependencies from './checkExtensionDependencies';
/* /*
Routes uniquely define an entry point to: Routes uniquely define an entry point to:
@ -41,8 +40,6 @@ export default function buildModeRoutes({
}); });
modes.forEach(mode => { modes.forEach(mode => {
checkExtensionDependencies(mode, extensionManager);
// todo: for each route. add route to path. // todo: for each route. add route to path.
dataSourceNames.forEach(dataSourceName => { dataSourceNames.forEach(dataSourceName => {
const path = `/${mode.routeName}/${dataSourceName}`; const path = `/${mode.routeName}/${dataSourceName}`;

View File

@ -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;
}
}