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 = {
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,

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 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,

View File

@ -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"
}
]
}

View File

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

View File

@ -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,

View File

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

View File

@ -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({
<CombinedContextProvider>
<DragAndDropProvider>
{layoutTemplateData.current &&
studyInstanceUIDs?.length &&
studyInstanceUIDs[0] !== undefined &&
studyInstanceUIDs?.[0] !== undefined &&
allExtensionsLoaded &&
renderLayoutData({
...layoutTemplateData.current.props,
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 isValidMode = mode.isValidMode({ modalities });

View File

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

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