Skip to content
Draft
Show file tree
Hide file tree
Changes from 9 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
107 changes: 107 additions & 0 deletions platform/app/.webpack/writePluginImportsFile.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,58 @@ const fs = require('fs');
const os = require('os');
const path = require('path');

// Extra plugins injected via environment variables rather than

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My preference would be to have another pluginConfig.json file that gets pointed at with a separate ENV value, and can include the default pluginConfig. That way it can be tracked in the specific build without hitting the main set.
Then, as a customization, allow the modes and extensions for each mode list to be customizeable. The default versus optional is a bit tricky since it affects build time, but it could be setup so that a second declaration over-ride or merges with the first one using the customization $set notation.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd also suggest a way to include an https:// referenced plugin that gets dynamically loaded - see the customization PR.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, I think I'm following. Let me tinker with it a bit, and see what I can come up with.

(I'm chuffed that I've been able to reduce the ~100 file changes to one essential change.)

@awatson1978 awatson1978 Jun 23, 2026

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So, just added a APP_PLUGIN_CONFIG environment variable.

Example customPlugin.json:

  {
    "include": "./pluginConfig.json",
    "extensions": [
      {
        "packageName": "@acme/extension-remote",
        "importPath": "https://cdn.example.com/acme-remote.umd.js",
        "globalName": "AcmeRemote"
      }
    ]
  }

Still testing/confirming everything works.

// pluginConfig.json, so a deployment can add extensions/modes without editing
// tracked files:
//
// EXTRA_EXTENSIONS="@ohif/extension-foo,@bar/ext=~/code/bar-ext" \
// EXTRA_MODES="my-mode" yarn dev
//
// Each comma-separated entry is a package name, optionally followed by
// `=<directory>` pointing at the package root of an out-of-tree plugin (same
// semantics as a pluginConfig `directory` override — `.` and `~` prefixes are
// resolved by fromDirectory below). Entries without a directory are resolved
// like any declared plugin: from the extensions/ or modes/ workspaces, or from
// node_modules when installed as a dependency.
//
// The entries are appended to the in-memory pluginConfig before any of the
// caches below are built, so name declaration, alias resolution, asset copying
// and the generated pluginImports.js all treat them exactly like plugins
// declared in pluginConfig.json.
//
// Companion-mode auto-detection: if an EXTRA_EXTENSIONS package bundles a mode in
// a `mode/` subdirectory, that mode is registered automatically (see below), so
// `EXTRA_EXTENSIONS=<ext>` alone brings in the extension and its mode without a
// separate EXTRA_MODES entry.
/**
* Parse a comma-separated EXTRA_EXTENSIONS / EXTRA_MODES env value into the
* plugin-entry shape used by pluginConfig.
*
* @param {string} [envValue] Comma-separated list of `packageName` or
* `packageName=<directory>` entries. Empty/undefined yields an empty list.
* @returns {Array<{packageName: string, directory?: string}>} One entry per
* parsed plugin; `directory` is present only when a `=<directory>` override
* was supplied. Entries with no package name are dropped.
*/
function parseExtraPlugins(envValue) {
if (!envValue) {
return [];
}
return envValue
.split(',')
.map(entry => {
const [packageName, directory] = entry.trim().split('=');
return directory ? { packageName, directory } : { packageName };
})
.filter(plugin => plugin.packageName);
}

pluginConfig.extensions = [
...(pluginConfig.extensions || []),
...parseExtraPlugins(process.env.EXTRA_EXTENSIONS),
];
pluginConfig.modes = [...(pluginConfig.modes || []), ...parseExtraPlugins(process.env.EXTRA_MODES)];

const autogenerationDisclaimer = `
// THIS FILE IS AUTOGENERATED AS PART OF THE EXTENSION AND MODE PLUGIN PROCESS.
// IT SHOULD NOT BE MODIFIED MANUALLY \n`;
Expand Down Expand Up @@ -118,6 +170,61 @@ const fromDirectory = (srcDir, dirPath) => {
const APP_SRC_DIR = path.resolve(__dirname, '../src');
const REPO_ROOT = path.resolve(__dirname, '../../../');

// Resolve an injected EXTRA_EXTENSIONS entry to its package root: an explicit
// `=<directory>` override wins, otherwise look it up among the in-tree
// extensions/ workspaces by package name.
function resolveInjectedExtensionDir(entry) {
if (entry.directory) {
return fromDirectory(APP_SRC_DIR, entry.directory);
}
const root = path.join(REPO_ROOT, 'extensions');
if (!fs.existsSync(root)) {
return undefined;
}
for (const dir of fs.readdirSync(root)) {
const pkgJsonPath = path.join(root, dir, 'package.json');
if (!fs.existsSync(pkgJsonPath)) {
continue;
}
try {
if (JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8')).name === entry.packageName) {
return path.join(root, dir);
}
} catch {
// ignore an unparseable package.json
}
}
return undefined;
}

// Auto-register a companion mode bundled inside an injected extension.
// Convention: if an EXTRA_EXTENSIONS package contains a `mode/` subdirectory
// with its own package.json, register that mode automatically — so
// `EXTRA_EXTENSIONS=<ext>` alone pulls in the extension AND its bundled mode,
// with no separate EXTRA_MODES entry. An explicit EXTRA_MODES still wins (a mode
// already present by name is left untouched).
for (const ext of parseExtraPlugins(process.env.EXTRA_EXTENSIONS)) {
const extDir = resolveInjectedExtensionDir(ext);
if (!extDir) {
continue;
}
const modeDir = path.join(extDir, 'mode');
const modePkgJsonPath = path.join(modeDir, 'package.json');
if (!fs.existsSync(modePkgJsonPath)) {
continue;
}
let modeName;
try {
modeName = JSON.parse(fs.readFileSync(modePkgJsonPath, 'utf8')).name;
} catch {
continue;
}
if (!modeName || pluginConfig.modes.some(m => extractName(m) === modeName)) {
continue;
}
pluginConfig.modes.push({ packageName: modeName, directory: modeDir });
}

// The set of plugin package names declared in pluginConfig.json. Resolution and
// asset copying are driven entirely by this list — a package present in the
// extensions/ or modes/ workspaces but NOT listed here is ignored, and an
Expand Down
14 changes: 13 additions & 1 deletion platform/app/src/routes/Mode/Mode.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,18 @@ export default function ModeRoute({
return;
}

// A data source registered by an extension (e.g. one entered via a SMART
// on FHIR launch) may not be active yet on the render where extension
// dependencies finish loading. Bail out and re-run once it appears —
// `dataSource` is in the dependency array below.
if (!dataSource) {
console.warn(
'[ModeRoute] Data source not available after extension dependencies loaded. Active:',
extensionManager.activeDataSourceName
);
return;
}

// Todo: this should not be here, data source should not care about params
const initializeDataSource = async (params, query) => {
await dataSource.initialize({
Expand All @@ -131,7 +143,7 @@ export default function ModeRoute({
return () => {
layoutTemplateData.current = null;
};
}, [location, ExtensionDependenciesLoaded]);
}, [location, ExtensionDependenciesLoaded, dataSource]);

/**
* Validates study existence before loading the viewer.
Expand Down
54 changes: 27 additions & 27 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.