Skip to content
Draft
Show file tree
Hide file tree
Changes from all 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
14 changes: 14 additions & 0 deletions platform/app/.env
Original file line number Diff line number Diff line change
Expand Up @@ -9,3 +9,17 @@
PUBLIC_URL=/
APP_CONFIG=config/default.js
USE_HASH_ROUTER=false

# Plugin composition (see platform/app/.webpack/writePluginImportsFile.js).
#
# APP_PLUGIN_CONFIG selects a tracked override pluginConfig file. It can `include`
# the default pluginConfig.json and layer extensions/modes/public over it — plain
# arrays append (de-duped by packageName), and immutability-helper commands like
# { "$set": [...] } replace a whole list. See platform/app/pluginConfig.example.json.
# APP_PLUGIN_CONFIG=./pluginConfig.example.json
#
# EXTRA_EXTENSIONS / EXTRA_MODES inject plugins by name (comma-separated), appended
# last — after the default and any APP_PLUGIN_CONFIG override. A package may be
# followed by `=<dir>` to point at an out-of-tree package root.
# EXTRA_EXTENSIONS=@ohif/extension-foo
# EXTRA_MODES=my-mode
244 changes: 242 additions & 2 deletions platform/app/.webpack/writePluginImportsFile.js
Original file line number Diff line number Diff line change
@@ -1,13 +1,198 @@
const pluginConfig = require('../pluginConfig.json');
const fs = require('fs');
const os = require('os');
const path = require('path');
// immutability-helper powers the $set / $push / $apply notation an
// APP_PLUGIN_CONFIG override can use — the same library and commands the OHIF
// CustomizationService applies (platform/core/src/services/CustomizationService).
const update = require('immutability-helper').default || require('immutability-helper');

// The static default plugin config, and the directory that a relative
// APP_PLUGIN_CONFIG path (and its `include`s) resolve against.
const APP_DIR = path.resolve(__dirname, '..');
const BASE_PLUGIN_CONFIG_PATH = path.join(APP_DIR, 'pluginConfig.json');

// 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.
//
// The plugin config is assembled in three layers, each overriding the previous:
//
// 1. pluginConfig.json the static, tracked default (never edited here)
// 2. APP_PLUGIN_CONFIG=<file> an optional tracked override file. It may
// `include` the default (and/or other files)
// and layer extensions/modes/public over it —
// plain arrays append (de-duped by package
// name), immutability-helper commands like
// { "$set": [...] } replace a whole list.
// 3. EXTRA_EXTENSIONS / EXTRA_MODES names injected via env, appended last.
//
// Layer 2 lets a build pin exactly which plugins it ships in its own tracked file
// without touching the shared default; layer 3 keeps the lightweight, 12-Factor
// runtime-injection path. See loadPluginConfig / mergePluginConfig below.
/**
* 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);
}

// Resolve a config path. ~ expands to the home dir, absolute paths are used
// verbatim, and everything else (including ./ and ../) resolves against `fromDir`.
function resolveConfigPath(fromDir, configPath) {
if (path.isAbsolute(configPath)) return configPath;
if (configPath[0] === '~') return path.join(os.homedir(), configPath.substring(1));
return path.resolve(fromDir, configPath);
}

function readPluginConfigJson(file) {
if (!fs.existsSync(file)) {
throw new Error(`[pluginConfig] config file not found: ${file}`);
}
return JSON.parse(fs.readFileSync(file, 'utf8'));
}

// True when `val` is an immutability-helper command spec (e.g. { $set: [...] },
// { $push: [...] }) rather than a plain value to append or replace.
function isCommandSpec(val) {
return (
val !== null &&
typeof val === 'object' &&
!Array.isArray(val) &&
Object.keys(val).some(key => key.startsWith('$'))
);
}

// Append `overrideList` onto `baseList`, de-duped by plugin package name: an
// override entry whose name matches a base entry REPLACES it (so a build can pin
// a version or flip `default`); otherwise it is appended.
function mergePluginList(baseList, overrideList) {
const result = Array.isArray(baseList) ? [...baseList] : [];
const indexByName = new Map(result.map((entry, i) => [extractName(entry), i]));
for (const entry of overrideList) {
const name = extractName(entry);
if (name && indexByName.has(name)) {
result[indexByName.get(name)] = entry;
} else {
if (name) indexByName.set(name, result.length);
result.push(entry);
}
}
return result;
}

// Merge an override config over a base config. For each key (except `include`):
// - an immutability-helper command spec ({ $set: [...] }, ...) is applied via
// immutability-helper `update`, the same notation the CustomizationService uses;
// - a plain array is appended to the base list, de-duped by package name;
// - anything else replaces the base value.
function mergePluginConfig(base, override) {
const merged = { ...base };
for (const key of Object.keys(override)) {
if (key === 'include') continue;
const val = override[key];
if (isCommandSpec(val)) {
merged[key] = update(merged[key] === undefined ? [] : merged[key], val);
} else if (Array.isArray(val)) {
merged[key] = mergePluginList(merged[key], val);
} else {
merged[key] = val;
}
}
return merged;
}

// Load a config file and recursively resolve its `include` chain (a string or
// array of paths, each relative to the including file's own directory). Every
// included config forms the base that the file then merges over. `seen` guards
// against include cycles.
function loadConfigWithIncludes(file, seen) {
const abs = path.resolve(file);
if (seen.has(abs)) {
throw new Error(`[pluginConfig] circular include detected at: ${abs}`);
}
seen.add(abs);
const raw = readPluginConfigJson(abs);
const includes = raw.include ? (Array.isArray(raw.include) ? raw.include : [raw.include]) : [];
let base = {};
for (const inc of includes) {
base = mergePluginConfig(base, loadConfigWithIncludes(resolveConfigPath(path.dirname(abs), inc), seen));
}
return mergePluginConfig(base, raw);
}

// Layers 1 + 2 (see header). With no APP_PLUGIN_CONFIG the result is exactly the
// static default — identical to before this feature. With it set, the override
// file is the root: it typically `include`s the default to extend it, then layers
// its own extensions/modes/public over the result.
//
// TODO(scope-B): per-mode dependency override. A reviewer asked to also let a
// build reshape an INDIVIDUAL mode's internal extensionDependencies / route panels
// (e.g. swap a panel in mode-longitudinal without forking it). That data lives in
// each mode's SOURCE, not in pluginConfig, so it cannot be expressed by this
// build-time merge — it needs a runtime customization hook in the mode/route
// loading path. Deliberately out of scope here; tracked for a follow-up.
function loadPluginConfig() {
const overridePath = process.env.APP_PLUGIN_CONFIG;
if (!overridePath) {
return readPluginConfigJson(BASE_PLUGIN_CONFIG_PATH);
}
return loadConfigWithIncludes(resolveConfigPath(APP_DIR, overridePath), new Set());
}

// Layer 1 + 2: static default, optionally replaced/extended by APP_PLUGIN_CONFIG.
const pluginConfig = loadPluginConfig();

// Layer 3 (last word): names injected via EXTRA_EXTENSIONS / EXTRA_MODES are
// appended on top of whatever the file layers produced.
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`;

const extractName = val => (typeof val === 'string' ? val : val.packageName);
function extractName(val) {
return typeof val === 'string' ? val : val.packageName;
}

const publicURL = process.env.PUBLIC_URL || '/';

Expand Down Expand Up @@ -118,6 +303,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
16 changes: 16 additions & 0 deletions platform/app/pluginConfig.example.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
{
"comment": "Example APP_PLUGIN_CONFIG override. Select it with `APP_PLUGIN_CONFIG=./pluginConfig.example.json`. It `include`s the static default pluginConfig.json and layers over it: plain arrays append (de-duped by packageName, an override entry replacing a same-named base entry), while immutability-helper commands replace instead — e.g. `\"modes\": { \"$set\": [ { \"packageName\": \"@ohif/mode-basic\" } ] }` would ship ONLY the basic mode. EXTRA_EXTENSIONS / EXTRA_MODES still append on top of whatever this file produces. See platform/app/.webpack/writePluginImportsFile.js.",
"include": "./pluginConfig.json",
"extensions": [
{
"packageName": "@ohif/extension-test",
"version": "3.0.0"
}
],
"modes": [
{
"packageName": "@ohif/mode-test",
"version": "3.0.0"
}
]
}
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
Loading