feat: Add customization URL parameter (#5992)

* Add customization URL parameter

* fix: Preserve should be customizeable

* Update customizations docs

* fix: Overlay items on patient name

* Add customization test

* Fix resolve to absolute path

* fix: Warn on no data in load

* Remove unused customization stuff

* fix: PR comments

* Update stored parameters to only use an array for mulitples

* Remove requires ohif.* special call out

* Remove strict mode

* PR comments

* Document segmentation examples

* Add three examples as requested

* PR comments

* lock

* Remove old customizatoin export

* fix: Ordering issues on customization loads

* fix: Use correct default for dev builds app config

* Fixes for conflicts

* chore: restore pnpm-lock.yaml to match master

The lockfile diff was incidental peer-descriptor churn and carried no
functional dependency change. It tripped the CircleCI security-audit gate
(which only runs when pnpm-lock.yaml is in the PR diff), surfacing a
pre-existing critical `decompress` transitive vuln that also exists on
master. Restoring master's lockfile removes the audit trigger.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(ci): restore json5 lockfile entry; ignore unfixable decompress GHSA

The previous commit restored pnpm-lock.yaml from master, which dropped the
json5@2.2.3 entry that platform/core legitimately depends on (JSONC parsing
for the customization feature). That broke `--frozen-lockfile` install
(ERR_PNPM_OUTDATED_LOCKFILE). This restores the correct lockfile.

Because the lockfile must change (json5), the CircleCI security-audit gate
runs and previously failed on a critical `decompress` <=4.2.1 zip-slip
advisory. This is a pre-existing transitive vuln (present on master too) with
no published patch — decompress's latest release is 4.2.1, so no version
bump/override can resolve it. It reaches the tree only via @itk-wasm/dam, a
build/data-asset extraction tool under @cornerstonejs/labelmap-interpolation.

Add GHSA-mp2f-45pm-3cg9 to the existing pnpm-workspace.yaml auditConfig
ignoreGhsas accepted-risk list, matching how the repo already exempts other
build-tooling advisories. `pnpm audit --audit-level high` now passes locally
(1 critical ignored, 0 high).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(e2e): fix visitStudy URL encoding that broke mpr2 study load

The visitStudy rewrite (added for the ?customization= option) built the URL
with new URLSearchParams({ StudyInstanceUIDs: studyInstanceUID }), which
percent-encodes the value. mpr2.spec.ts embeds an extra param in the UID
string ('<uid>&hangingprotocolid=mpr'), so the & and = were encoded and the
whole thing collapsed into one invalid StudyInstanceUIDs value -> the study
could not be found ('studies are not available'), the viewer never rendered,
and the side-panel-header-right click timed out.

Restore master's raw concatenation for StudyInstanceUIDs (so embedded params
survive as separate query params) while still appending the customization
option separately. Only mpr2 embeds & in the UID, matching the single failure.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* PR comments

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bill WallaceandClaude Opus 4.8 authored and GitHub committed 2026-07-07 15:30:27 -04:00
1 parent 3d9a17bc0c
commit 3dd5c70cb2
58 files changed
+3411 -586

No files matched your search

+1
View File
@@ -49,6 +49,7 @@
"gl-matrix": "3.4.3",
"immutability-helper": "3.1.1",
"isomorphic-base64": "1.0.2",
"json5": "2.2.3",
"lodash.clonedeep": "4.5.0",
"lodash.isequal": "4.5.0",
"moment": "2.30.1",
@@ -0,0 +1,50 @@
import CustomizationService from './CustomizationService';
const commandsManager = {};
describe('CustomizationService.init (extension default/global deduplication)', () => {
it('does not re-merge the same extension customization modules on repeated init', () => {
const getModuleEntry = jest.fn((id: string) => {
if (id === 'ext1.customizationModule.default') {
return { value: { fromDefault: { $set: 1 } } };
}
if (id === 'ext1.customizationModule.global') {
return { value: { fromGlobal: { $set: 'g' } } };
}
return undefined;
});
const extensionManager = {
registeredExtensionIds: ['ext1'],
getRegisteredExtensionIds: () => ['ext1'],
getModuleEntry,
};
const service = new CustomizationService({ commandsManager, configuration: {} });
service.init(extensionManager as any);
service.init(extensionManager as any);
expect(getModuleEntry).toHaveBeenCalledTimes(2);
expect(service.getCustomization('fromDefault')).toBe(1);
expect(service.getCustomization('fromGlobal')).toBe('g');
});
it('merges a module the first time it appears on a later init', () => {
const moduleEntries: Record<string, { value: Record<string, unknown> }> = {};
const extensionManager = {
registeredExtensionIds: ['ext2'],
getRegisteredExtensionIds: () => ['ext2'],
getModuleEntry: (id: string) => moduleEntries[id],
};
const service = new CustomizationService({ commandsManager, configuration: {} });
service.init(extensionManager as any);
expect(service.getCustomization('late')).toBeUndefined();
moduleEntries['ext2.customizationModule.default'] = { value: { late: { $set: true } } };
service.init(extensionManager as any);
expect(service.getCustomization('late')).toBe(true);
});
});
@@ -0,0 +1,130 @@
import CustomizationService, { GENERAL_MODE_KEY } from './CustomizationService';
const commandsManager = {};
const policy = {
prefixes: { default: './customizations/' },
};
/** Minimal ExtensionManager stub exposing the appConfig the URL policy reads. */
function makeExtensionManager(appConfig: Record<string, unknown> = {}) {
return {
appConfig: { customizationUrlPrefixes: policy.prefixes, ...appConfig },
registeredExtensionIds: [],
getRegisteredExtensionIds: () => [],
getModuleEntry: () => undefined,
} as any;
}
describe('CustomizationService phase-tagged loading', () => {
it('applies bootstrap before extensions and global after, both Global scope', async () => {
const service = new CustomizationService({
commandsManager,
configuration: {
bootstrap: { early: { $set: 'pre' } },
global: { late: { $set: 'post' } },
},
});
const extensionManager = makeExtensionManager();
await service.loadAndApplyBootstrapCustomizations(extensionManager);
// bootstrap is applied immediately; global is not yet.
expect(service.getCustomization('early')).toBe('pre');
expect(service.getCustomization('late')).toBeUndefined();
service.init(extensionManager);
service.applyGlobalCustomizations();
expect(service.getCustomization('late')).toBe('post');
expect(service.getCustomizations(service.Scope.Global).get('early')).toBeDefined();
});
it('does not treat a phase-tagged config as legacy Global references', () => {
const service = new CustomizationService({
commandsManager,
configuration: { global: { a: { $set: 1 } } },
});
service.init(makeExtensionManager());
// `global` must NOT be added as a customization id during init; it is a phase.
expect(service.getCustomization('global')).toBeUndefined();
expect(service.getCustomization('a')).toBeUndefined();
});
it('applies the general mode block first, then the mode-specific block', () => {
const service = new CustomizationService({
commandsManager,
configuration: {
mode: {
[GENERAL_MODE_KEY]: { greeting: { $set: 'general' }, shared: { $set: 'general' } },
viewer: { greeting: { $set: 'viewer' } },
},
},
});
service.init(makeExtensionManager());
service.onModeEnter();
service.applyModeCustomizations(['viewer']);
// mode-specific overrides general; non-overridden general value remains.
expect(service.getCustomization('greeting')).toBe('viewer');
expect(service.getCustomization('shared')).toBe('general');
});
it('matches a mode block by id OR routeName', () => {
const service = new CustomizationService({
commandsManager,
configuration: {
mode: { viewer: { fromRouteName: { $set: true } } },
},
});
service.init(makeExtensionManager());
service.onModeEnter();
service.applyModeCustomizations(['@ohif/mode-longitudinal', 'viewer']);
expect(service.getCustomization('fromRouteName')).toBe(true);
});
it('clears mode-scope customizations on re-enter so a different mode does not leak', () => {
const service = new CustomizationService({
commandsManager,
configuration: {
mode: {
viewer: { onlyViewer: { $set: true } },
segmentation: { onlySeg: { $set: true } },
},
},
});
service.init(makeExtensionManager());
service.onModeEnter();
service.applyModeCustomizations(['viewer']);
expect(service.getCustomization('onlyViewer')).toBe(true);
service.onModeEnter();
service.applyModeCustomizations(['segmentation']);
expect(service.getCustomization('onlyViewer')).toBeUndefined();
expect(service.getCustomization('onlySeg')).toBe(true);
});
it('applies phase blocks from URL-loaded modules and merges $apply over extension defaults', async () => {
const service = new CustomizationService({ commandsManager, configuration: {} });
const extensionManager = makeExtensionManager();
// Seed an extension-style default so $apply has something to build on.
service.init(extensionManager);
service.setCustomizations({ 'list.columns': ['a', 'b'] }, service.Scope.Default);
const importFn = jest.fn(async () => ({
global: { 'list.columns': { $push: ['c'] } },
mode: { '*': { banner: { $set: 'all' } }, viewer: { banner: { $set: 'viewer' } } },
}));
await service.loadCustomizationModules(['A'], { policy, importFn });
service.applyGlobalCustomizations();
expect(service.getCustomization('list.columns')).toEqual(['a', 'b', 'c']);
service.applyModeCustomizations(['viewer']);
expect(service.getCustomization('banner')).toBe('viewer');
});
});
@@ -0,0 +1,148 @@
import CustomizationService from './CustomizationService';
const commandsManager = {};
const policy = {
prefixes: { default: './customizations/' },
};
describe('CustomizationService.requires (URL customization modules)', () => {
let service: CustomizationService;
beforeEach(() => {
service = new CustomizationService({ commandsManager, configuration: {} });
});
it('loads a single module', async () => {
const importFn = jest.fn(async (url: string) => ({
global: { entry: { value: url } },
}));
const loaded = await service.requires(['A'], { policy, importFn });
expect(loaded).toHaveLength(1);
expect(loaded[0].request.name).toBe('A');
expect(loaded[0].module.global).toBeDefined();
expect(importFn).toHaveBeenCalledTimes(1);
});
it('returns immediately when the same module is already loaded', async () => {
const importFn = jest.fn(async (url: string) => ({
global: { entry: { value: url } },
}));
await service.requires(['A'], { policy, importFn });
expect(importFn).toHaveBeenCalledTimes(1);
const loadedAgain = await service.requires(['A'], { policy, importFn });
expect(loadedAgain).toHaveLength(0);
expect(importFn).toHaveBeenCalledTimes(1);
});
it('loads dependencies first via module requires', async () => {
const importFn = jest.fn(async (url: string) => {
if (url.endsWith('/A.jsonc')) {
return {
global: { 'pkg.A': { value: 'A' } },
requires: ['B'],
};
}
if (url.endsWith('/B.jsonc')) {
return {
global: { 'pkg.B': { value: 'B' } },
};
}
return {};
});
const loaded = await service.requires(['A'], { policy, importFn });
expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']);
});
it('handles cycles per the spec: A requires B, B requires A => B then A', async () => {
const importFn = jest.fn(async (url: string) => {
if (url.endsWith('/A.jsonc')) {
return {
global: { 'pkg.A': { value: 'A' } },
requires: ['B'],
};
}
if (url.endsWith('/B.jsonc')) {
return {
global: { 'pkg.B': { value: 'B' } },
requires: ['A'],
};
}
return {};
});
const loaded = await service.requires(['A'], { policy, importFn });
expect(loaded.map(l => l.request.name)).toEqual(['B', 'A']);
expect(importFn).toHaveBeenCalledTimes(2);
});
it('does not treat customization field refs as URL dependencies', async () => {
const importFn = jest.fn(async () => ({
global: {
'viewportOverlay.topLeft.X': { customization: 'ohif.overlayItem' },
},
}));
const loaded = await service.requires(['A'], { policy, importFn });
expect(loaded).toHaveLength(1);
expect(importFn).toHaveBeenCalledTimes(1);
});
it('parses comma-separated names like the URL query integration', async () => {
const importFn = jest.fn(async () => ({
global: { 'pkg.X': {} },
}));
const loaded = await service.requires(['A', 'B', 'C'], { policy, importFn });
expect(loaded.map(l => l.request.name)).toEqual(['A', 'B', 'C']);
expect(importFn).toHaveBeenCalledTimes(3);
});
it('throws and loads nothing when any entry is rejected', async () => {
const importFn = jest.fn(async () => ({
global: {},
}));
await expect(
service.requires(['A', '/missing/foo', '../escape'], { policy, importFn })
).rejects.toThrow(/refusing to load customization/);
expect(importFn).not.toHaveBeenCalled();
});
it('warns and skips when import fails', async () => {
const importFn = jest.fn(async () => {
throw new Error('404');
});
const warn = jest.fn();
const loaded = await service.requires(['A'], {
policy,
importFn,
logger: { warn, error: jest.fn() },
});
expect(loaded).toHaveLength(0);
expect(warn).toHaveBeenCalled();
});
it('warns and skips when the module has no customization payload', async () => {
const importFn = jest.fn(async () => ({}));
const warn = jest.fn();
const loaded = await service.requires(['A'], {
policy,
importFn,
logger: { warn, error: jest.fn() },
});
expect(loaded).toHaveLength(0);
expect(warn).toHaveBeenCalled();
});
it('applyCustomizationUrlSearchParams delegates to requires', async () => {
const importFn = jest.fn(async () => ({
global: { 'pkg.X': {} },
}));
const params = new URLSearchParams();
params.append('customization', 'A,B');
params.append('customization', 'C');
await service.applyCustomizationUrlSearchParams(params, { policy, importFn });
expect(importFn).toHaveBeenCalledTimes(3);
});
});
@@ -1,8 +1,33 @@
import update, { extend } from 'immutability-helper';
import JSON5 from 'json5';
import { PubSubService } from '../_shared/pubSubServiceInterface';
import type { Customization } from './types';
import type { CommandsManager } from '../../classes';
import type { ExtensionManager } from '../../extensions';
import type ExtensionManager from '../../extensions/ExtensionManager';
import { getCustomizationUrlPolicy } from './customizationUrl';
import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload';
import { resolveCustomizationUrl } from './resolve';
import {
parseCustomizationParams,
validateCustomizationRequests,
} from './validate';
import type { ValidatedCustomization } from './validate';
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
import type {
CustomizationModule,
CustomizationPhaseInput,
LoadedCustomization,
LoadOptions,
PhasedCustomizationConfig,
} from './customizationUrlTypes';
/**
* Reserved key in a `mode` phase block for the "general" customizations that
* apply to every mode. The general block is applied FIRST on each mode enter;
* a block keyed by the entered mode's id / routeName is applied after it so a
* single mode can override the general values.
*/
export const GENERAL_MODE_KEY = '*';
const EVENTS = {
MODE_CUSTOMIZATION_MODIFIED: 'event::CustomizationService:modeModified',
@@ -28,7 +53,8 @@ export enum CustomizationScope {
/**
* Default customizations that serve as fallbacks when no global or mode-specific
* customizations are defined. These can only be defined once.
* customizations are defined. These are not cleared when the service re-inits
* for a mode change; only Mode scope is reset.
*/
Default = 'default',
}
@@ -89,10 +115,9 @@ export default class CustomizationService extends PubSubService {
private modeCustomizations = new Map<string, Customization>();
/**
* A collection of default customizations used as fallbacks. These serve as
* the base configuration and are registered at setup. Default customizations
* provide baseline values that can be overridden by mode or global customizations.
* Use these for cases where default values are necessary for predictable behavior.
* A collection of default customizations used as fallbacks. Entries are merged
* over time (including from `init()` re-reading extension default modules) and
* are not cleared on mode change. Mode and Global scopes override these values.
*/
private defaultCustomizations = new Map<string, Customization>();
@@ -103,41 +128,334 @@ export default class CustomizationService extends PubSubService {
private transformedCustomizations = new Map<string, Customization>();
private configuration: AppTypes.Config;
/**
* URL customization modules already imported and applied (key = normalized `/prefix/name`).
* Entries are kept for the lifetime of the page: repeated loads skip imports, and the app
* normally applies `?customization=` only at bootstrap (see {@link applyWindowUrlCustomizations}).
*/
private _urlCustomizationLoaded = new Map<string, LoadedCustomization>();
private _urlCustomizationPending = new Map<string, Promise<LoadedCustomization | null>>();
/**
* Every URL customization module resolved this page session, in load order
* (dependencies before dependents). The lifecycle phase appliers
* ({@link applyBootstrapCustomizations}, {@link applyGlobalCustomizations},
* {@link applyModeCustomizations}) iterate this list so a module's phase
* blocks are applied at the right time regardless of when it was fetched.
*/
private _resolvedUrlModules: LoadedCustomization[] = [];
/**
* Normalized form of `appConfig.customizationService`. Either a phase-tagged
* config ({@link PhasedCustomizationConfig}) or, for the legacy array/object
* form, the references to add (Global) during {@link init}.
*/
private _customizationConfig: {
phased?: PhasedCustomizationConfig;
legacyReferences?: unknown;
} | null = null;
/** Id/aliases of the mode currently entered, used to re-apply mode phase blocks. */
private _currentModeIds: string[] = [];
/**
* Extension module entry ids (e.g. `${extensionId}.customizationModule.default`) whose
* default/global payloads have already been merged via {@link init}. Matches the URL
* loader pattern: repeated {@link init} skips work for the same slot so immutability-style
* merges are not applied twice. A slot is recorded only after a module was present and applied;
* if a module appears only on a later {@link init} (e.g. a newly registered extension), it is merged then.
*/
private _extensionCustomizationModuleApplied = new Set<string>();
constructor({ configuration, commandsManager }) {
super(EVENTS);
this.configuration = configuration;
this.commandsManager = commandsManager;
}
// ===========================================================================
// Public API
// ===========================================================================
/**
* Clears mode customizations and merges each extension's `customizationModule.default` /
* `customizationModule.global` into the service. Safe to call multiple times (e.g. from
* {@link onModeEnter}): each extension module slot is merged at most once per page session,
* matching the deduplication pattern used for URL-loaded modules in {@link requires}.
* Slots with no module yet are left unmarked so a later call can merge when the module appears.
*/
public init(extensionManager: ExtensionManager): void {
this.extensionManager = extensionManager;
// Clear defaults as those are defined by the customization modules
this.defaultCustomizations.clear();
// Clear modes because those are defined in onModeEnter functions.
// Mode customizations are defined per mode in onModeEnter; reset them here.
// Default customizations are not cleared — they are merged again from
// extension modules below so definitions stay available across mode changes.
this.modeCustomizations.clear();
this.extensionManager.getRegisteredExtensionIds().forEach(extensionId => {
const keyDefault = `${extensionId}.customizationModule.default`;
const defaultCustomizations = this._findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this._addReference(value, CustomizationScope.Default);
if (!this._extensionCustomizationModuleApplied.has(keyDefault)) {
const defaultCustomizations = this._findExtensionValue(keyDefault);
if (defaultCustomizations) {
const { value } = defaultCustomizations;
this._addReference(value, CustomizationScope.Default);
this._extensionCustomizationModuleApplied.add(keyDefault);
}
}
const keyGlobal = `${extensionId}.customizationModule.global`;
const globalCustomizations = this._findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this._addReference(value, CustomizationScope.Global);
if (!this._extensionCustomizationModuleApplied.has(keyGlobal)) {
const globalCustomizations = this._findExtensionValue(keyGlobal);
if (globalCustomizations) {
const { value } = globalCustomizations;
this._addReference(value, CustomizationScope.Global);
this._extensionCustomizationModuleApplied.add(keyGlobal);
}
}
});
// Only add references for the configuration once.
if (!this.configuration?._hasBeenAdded) {
this.addReferences(this.configuration);
// Only add references for the configuration once. The phase-tagged config
// form (bootstrap/global/mode) is applied by the lifecycle appliers
// instead, so only the legacy array/object form is added here as Global.
const config = this._getCustomizationConfig();
if (config.legacyReferences !== undefined && !this.configuration?._hasBeenAdded) {
this.addReferences(config.legacyReferences);
Object.defineProperty(this.configuration, '_hasBeenAdded', { value: true, writable: false });
}
}
/**
* Memoized, normalized view of `appConfig.customizationService`. Detects the
* phase-tagged form (any of `requires` / `bootstrap` / `global` / `mode`)
* and otherwise treats the value as legacy Global references.
*/
private _getCustomizationConfig(): { phased?: PhasedCustomizationConfig; legacyReferences?: unknown } {
if (!this._customizationConfig) {
this._customizationConfig = normalizeCustomizationConfig(this.configuration);
}
return this._customizationConfig;
}
/**
* Loads and applies `?customization=` modules from `window.location.search`.
*
* **Throws on disallowed values.** A `?customization=` value whose prefix is
* not in `appConfig.customizationUrlPrefixes` (the default — the feature is off
* until prefixes are configured) rejects this promise, which by design aborts
* app bootstrap rather than silently ignoring the request.
*
* **Intended SPA behavior:** The shell typically calls this once during startup. It does not
* run again on client-side route changes. The query key `customization` may still appear in
* URLs (for example preserved by worklist navigation) without implying that modules are
* re-evaluated on every navigation. Modules resolved here are also deduplicated by normalized
* URL for the lifetime of the page in {@link requires}. To pick up a different `?customization=`
* set, use a full page load or call {@link applyCustomizationUrlSearchParams} /
* {@link requires} from your own integration code when appropriate.
*
* **What is loaded:** Each `?customization=` entry resolves to a JSONC file (JSON with
* comments / trailing commas) under a configured prefix directory. The file is fetched and
* parsed as **data** — it is never executed. Executable code (plugins, modes, extensions)
* loads only through `pluginConfig.json`, never from the customization URL path. A module's
* `global` payload is applied as global customizations and its `requires` are loaded first.
*/
public async applyWindowUrlCustomizations(overrides?: Partial<LoadOptions>): Promise<void> {
if (typeof window === 'undefined') {
return;
}
await this.applyCustomizationUrlSearchParams(
new URLSearchParams(window.location.search),
overrides
);
}
/**
* Parses `?customization=` values from the search string and delegates to
* {@link requires}.
*/
public async applyCustomizationUrlSearchParams(
params: URLSearchParams,
overrides?: Partial<LoadOptions>
): Promise<void> {
const raws = parseCustomizationParams(params);
if (!raws.length) {
return;
}
await this.requires(raws, overrides);
}
/**
* Depth-first dynamic import of URL customization modules
* `requires` edges and `customization` field
* references are loaded before dependents. Already-loaded modules (same normalized key) are
* skipped for the rest of the page session; they are not unloaded when the address bar changes.
*
* Invalid query entries, resolve failures, failed imports, and modules without a
* customization payload are warned and skipped.
*/
public requires(
names: string | string[],
overrides?: Partial<LoadOptions>
): Promise<LoadedCustomization[]> {
return this.loadCustomizationModules(names, overrides).then(newlyLoaded => {
// Back-compat: a direct `requires()` / `applyWindowUrlCustomizations()`
// call applies the `global` slice immediately. The `bootstrap` and
// `mode` phases are driven by the boot orchestration
// ({@link loadAndApplyBootstrapCustomizations}) and {@link onModeEnter}.
this._applyLoadedUrlCustomizationModules(newlyLoaded);
return newlyLoaded;
});
}
/**
* Resolves (fetches + parses) the given URL customization modules and their
* `requires` dependencies depth-first, WITHOUT applying any phase block.
* Newly resolved modules are appended to {@link _resolvedUrlModules} in load
* order so the lifecycle appliers can apply each phase at the right time.
*
* Rejects (aborting the load) when any entry is disallowed by the policy.
*/
public loadCustomizationModules(
names: string | string[],
overrides?: Partial<LoadOptions>
): Promise<LoadedCustomization[]> {
const policy = overrides?.policy ?? getCustomizationUrlPolicy(this);
const list = (Array.isArray(names) ? names : [names])
.map(s => String(s).trim())
.filter(Boolean);
if (!list.length) {
return Promise.resolve([]);
}
const { valid, rejected } = validateCustomizationRequests(list, policy);
const logger = overrides?.logger || console;
// A `?customization=` value that is not allowed by the configured prefixes is
// a hard error: stop the load and let it propagate. The feature is off until
// `appConfig.customizationUrlPrefixes` allows a prefix, so on a default build
// any `?customization=` value throws here rather than being silently ignored.
if (rejected.length) {
const details = rejected.map(r => `"${r.raw}" (${r.reason})`).join('; ');
return Promise.reject(
new Error(
`[customizationUrl] refusing to load customization(s): ${details}. ` +
`Allowed prefixes are configured in appConfig.customizationUrlPrefixes.`
)
);
}
if (!valid.length) {
return Promise.resolve([]);
}
const importFn = overrides?.importFn ?? this._urlDefaultImport.bind(this);
const requestedSet = new Set<string>();
const newlyLoaded: LoadedCustomization[] = [];
return valid
.reduce(
(prev, request) =>
prev.then(() =>
this._urlCustomizationLoadOne(
request,
policy,
importFn,
logger,
requestedSet,
newlyLoaded
)
),
Promise.resolve() as Promise<void>
)
.then(() => {
this._resolvedUrlModules.push(...newlyLoaded);
return newlyLoaded;
});
}
/**
* Boot-time entry point that runs BEFORE extensions are registered. It:
* 1. records the ExtensionManager (so the URL policy / appConfig is readable),
* 2. resolves every customization module requested via the
* `appConfig.customizationService.requires` list and the `?customization=`
* URL parameter (their data is fetched once, up front — long before any
* mode loads), and
* 3. applies the `bootstrap` phase blocks.
*
* Rejects (aborting bootstrap) if a `?customization=` value is disallowed.
*/
public async loadAndApplyBootstrapCustomizations(
extensionManager: ExtensionManager,
overrides?: Partial<LoadOptions>
): Promise<void> {
this.extensionManager = extensionManager;
const config = this._getCustomizationConfig();
const names: string[] = [];
const requires = config.phased?.requires;
if (typeof requires === 'string' && requires) {
names.push(requires);
} else if (Array.isArray(requires)) {
names.push(...requires.filter(name => typeof name === 'string' && name));
}
if (typeof window !== 'undefined') {
names.push(...parseCustomizationParams(new URLSearchParams(window.location.search)));
}
if (names.length) {
await this.loadCustomizationModules(names, overrides);
}
this.applyBootstrapCustomizations();
}
/**
* Applies the `bootstrap` phase (Global scope) of the structured app config
* and every resolved URL module. App-config blocks apply first so URL modules
* layer on top.
*/
public applyBootstrapCustomizations(): void {
this._applyPhase('bootstrap');
}
/**
* Applies the `global` phase (Global scope) of the structured app config and
* every resolved URL module. Call after {@link init} so extension default /
* global customizations are already in place for `$apply`-style merges.
*/
public applyGlobalCustomizations(): void {
this._applyPhase('global');
}
/**
* Applies the `mode` phase (Mode scope) for the entered mode. The general
* block (`*`) is applied FIRST, then any block keyed by one of `modeIds`
* (the mode's id / routeName), so a single mode can override the general
* values. Call this AFTER `customizationService.onModeEnter()` has reset the
* mode scope (i.e. after `extensionManager.onModeEnter()`).
*/
public applyModeCustomizations(modeIds: string | string[]): void {
const ids = (Array.isArray(modeIds) ? modeIds : [modeIds]).filter(Boolean) as string[];
this._currentModeIds = ids;
const blocks = this._collectPhaseBlocks('mode') as Array<{
mode?: Record<string, CustomizationPhaseInput>;
}>;
// General first, for every source.
for (const block of blocks) {
const general = block.mode?.[GENERAL_MODE_KEY];
if (general) {
this.setCustomizations(general, CustomizationScope.Mode);
}
}
// Then mode-specific, for every source.
for (const block of blocks) {
for (const id of ids) {
const specific = block.mode?.[id];
if (specific) {
this.setCustomizations(specific, CustomizationScope.Mode);
}
}
}
}
public onModeEnter(): void {
this.clearTransformedCustomizations();
@@ -148,17 +466,6 @@ export default class CustomizationService extends PubSubService {
this.clearTransformedCustomizations();
}
private clearTransformedCustomizations(): void {
super.reset();
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
for (const key of modeCustomizationKeys) {
this.transformedCustomizations.delete(key);
}
this.modeCustomizations.clear();
}
/**
* Unified getter for customizations.
*
@@ -184,6 +491,14 @@ export default class CustomizationService extends PubSubService {
return newTransformed;
}
/**
* Returns a customization value, or the provided fallback when unset.
*/
public getValue<T = Customization>(customizationId: string, fallbackValue?: T): T | undefined {
const value = this.getCustomization(customizationId);
return (value === undefined ? fallbackValue : (value as T)) as T | undefined;
}
/**
* Takes an object with multiple properties, each property containing
* immutability-helper commands, and applies them one by one.
@@ -229,34 +544,6 @@ export default class CustomizationService extends PubSubService {
this._setCustomization(customizationId, customization, scope);
}
/**
* Internal method to set a single customization
*/
private _setCustomization(
customizationId: string,
customization: Customization,
scope: CustomizationScope = CustomizationScope.Mode
): void {
// if (typeof customization === 'string') {
// const extensionValue = this._findExtensionValue(customization);
// customization = extensionValue.value;
// }
switch (scope) {
case CustomizationScope.Global:
this.setGlobalCustomization(customizationId, customization);
break;
case CustomizationScope.Mode:
this.setModeCustomization(customizationId, customization);
break;
case CustomizationScope.Default:
this.setDefaultCustomization(customizationId, customization);
break;
default:
throw new Error(`Invalid customization scope: ${scope}`);
}
}
/**
* Gets all customizations for a given scope.
*
@@ -303,68 +590,6 @@ export default class CustomizationService extends PubSubService {
return result.$transform?.(this) || result;
}
/**
*
* Sets a mode-specific customization.
*
* This method allows you to define or update a customization that applies only to the current mode.
* Mode customizations are temporary and isolated, reset whenever a mode changes.
*
* @param customizationId - The unique identifier for the customization.
* @param customization - The customization object containing the desired settings.
*/
private setModeCustomization(customizationId: string, customization: Customization): void {
const defaultCustomization = this.defaultCustomizations.get(customizationId);
const modeCustomization = this.modeCustomizations.get(customizationId);
const globCustomization = this.globalCustomizations.get(customizationId);
const sourceCustomization =
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
const result = this._update(sourceCustomization, customization);
this.modeCustomizations.set(customizationId, result);
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
buttons: this.modeCustomizations,
button: this.modeCustomizations.get(customizationId),
});
}
private setGlobalCustomization(id: string, value: Customization): void {
const defaultCustomization = this.defaultCustomizations.get(id);
const globCustomization = this.globalCustomizations.get(id);
const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
this.globalCustomizations.set(id, this._update(sourceCustomization, value));
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private setDefaultCustomization(id: string, value: Customization): void {
if (this.defaultCustomizations.has(id)) {
console.warn(`Trying to update existing default for customization ${id}`);
}
this.transformedCustomizations.clear();
const sourceCustomization = this.defaultCustomizations.get(id);
this.defaultCustomizations.set(id, this._update(sourceCustomization, value));
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private _findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
}
/**
* Registers a custom command to be used in customization updates.
* @param commandName - The name of the command (without the $ prefix)
@@ -381,40 +606,6 @@ export default class CustomizationService extends PubSubService {
extend(commandName, handler);
}
/**
* Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.)
* Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update.
*/
private _update(oldValue: Customization | undefined, newValue: Customization): Customization {
if (!oldValue) {
oldValue = undefined;
}
// Use immutability-helper to apply the commands
// if $ is not part of the value in the json string, then we just return the newValue
if (!hasDollarKey(newValue)) {
return newValue;
}
const result = update(oldValue, newValue);
return result;
}
private _cloneIfNeeded(value: any) {
// If it's null/undefined or not an object, return as is
if (!value || typeof value !== 'object') {
return value;
}
// If it's an array, create a shallow copy
if (Array.isArray(value)) {
return [...value];
}
// Otherwise create a shallow copy of the object
return { ...value };
}
_addReference(value?: any, type = CustomizationScope.Global): void {
if (!value) {
return;
@@ -451,6 +642,362 @@ export default class CustomizationService extends PubSubService {
this._addReference(references, type);
}
}
// ===========================================================================
// Private methods
// ===========================================================================
private clearTransformedCustomizations(): void {
super.reset();
const modeCustomizationKeys = Array.from(this.modeCustomizations.keys());
for (const key of modeCustomizationKeys) {
this.transformedCustomizations.delete(key);
}
this.modeCustomizations.clear();
}
/**
* Default loader for a `?customization=` module. Customization files are
* **data**, not code: the file is fetched and parsed as JSONC (JSON with
* comments / trailing commas, via JSON5 which is a superset). It is never
* executed. Executable modules — plugins, modes and extensions — load only
* through `pluginConfig.json`, never from the customization URL path.
*/
private async _urlDefaultImport(url: string): Promise<any> {
if (typeof fetch !== 'function') {
throw new Error(`No fetch implementation available to load customization ${url}`);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(
`Failed to fetch customization ${url}: ${response.status} ${response.statusText}`
);
}
const text = await response.text();
return JSON5.parse(text);
}
private _normalizeImportedCustomizationModule(imported: any): CustomizationModule {
return imported && typeof imported === 'object' && 'customizations' in imported
? imported
: imported && typeof imported.default === 'object'
? imported.default
: imported;
}
private _collectUrlDependencyRefs(module: CustomizationModule): string[] {
const refs = new Set<string>();
const payload = getUrlCustomizationModulePayload(module);
if (!payload || typeof payload !== 'object') {
return Array.from(refs);
}
const moduleRequires = (payload as any).requires;
if (typeof moduleRequires === 'string' && moduleRequires) {
refs.add(moduleRequires);
} else if (Array.isArray(moduleRequires)) {
for (const id of moduleRequires) {
if (typeof id === 'string' && id) {
refs.add(id);
}
}
}
return Array.from(refs);
}
private _urlDependencyToRequest(
name: string,
policy: CustomizationUrlPolicy
): ValidatedCustomization | null {
// `requires` entries are module names to load; each is validated/resolved like any
// other URL customization request. Entries that don't resolve to a valid module
// (e.g. a bare customization-key reference) are rejected by validation and
// skipped — there is no namespace carve-out.
const result = validateCustomizationRequests([name], policy);
if (result.valid.length) {
return result.valid[0];
}
return null;
}
private _urlCustomizationLoadOne(
request: ValidatedCustomization,
policy: CustomizationUrlPolicy,
importFn: (url: string) => Promise<any>,
logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void },
requestedSet: Set<string>,
newlyLoaded: LoadedCustomization[]
): Promise<LoadedCustomization | null> {
const key = request.normalized;
if (this._urlCustomizationLoaded.has(key)) {
return Promise.resolve(this._urlCustomizationLoaded.get(key) || null);
}
if (this._urlCustomizationPending.has(key)) {
return this._urlCustomizationPending.get(key)!;
}
requestedSet.add(key);
const promise = this._urlCustomizationLoadOneBody(
request,
policy,
importFn,
logger,
requestedSet,
newlyLoaded
);
this._urlCustomizationPending.set(key, promise);
// Use then(success, failure) for cleanup — `finally` left rejections unhandled
// with the current Promise polyfill in the Jest/Node test stack.
promise.then(
() => this._urlCustomizationPending.delete(key),
() => this._urlCustomizationPending.delete(key)
);
return promise;
}
private _urlCustomizationLoadOneBody(
request: ValidatedCustomization,
policy: CustomizationUrlPolicy,
importFn: (url: string) => Promise<any>,
logger: { warn: (...args: any[]) => void; error: (...args: any[]) => void },
requestedSet: Set<string>,
newlyLoaded: LoadedCustomization[]
): Promise<LoadedCustomization | null> {
const key = request.normalized;
const importFailedSentinel = Symbol('importFailed');
let url: string;
try {
url = resolveCustomizationUrl(request, policy);
} catch (err) {
const msg = `[customizationUrl] failed to resolve "${request.raw}": ${(err as Error).message}`;
logger.warn(msg);
return Promise.resolve(null);
}
return importFn(url)
.catch(err => {
logger.warn(
`[customizationUrl] failed to import customization "${request.raw}" (${url})`,
err
);
return importFailedSentinel;
})
.then(importedOrSentinel => {
if (importedOrSentinel === importFailedSentinel) {
return null;
}
const imported = importedOrSentinel;
const module = this._normalizeImportedCustomizationModule(imported);
if (!module || typeof module !== 'object') {
const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): module is not an object`;
logger.warn(msg);
return null;
}
if (!getUrlCustomizationModulePayload(module)) {
const msg = `[customizationUrl] missing customization module "${request.raw}" (${url}): no customizations payload`;
logger.warn(msg);
return null;
}
const depRefs = this._collectUrlDependencyRefs(module);
let depsChain: Promise<unknown> = Promise.resolve();
for (const depRef of depRefs) {
depsChain = depsChain.then(() => {
const depRequest = this._urlDependencyToRequest(depRef, policy);
if (!depRequest || requestedSet.has(depRequest.normalized)) {
return undefined;
}
return this._urlCustomizationLoadOne(
depRequest,
policy,
importFn,
logger,
requestedSet,
newlyLoaded
);
});
}
return depsChain.then(() => {
const loaded: LoadedCustomization = { request, module, url };
this._urlCustomizationLoaded.set(key, loaded);
newlyLoaded.push(loaded);
return loaded;
});
});
}
private _applyLoadedUrlCustomizationModules(loaded: LoadedCustomization[]): void {
if (!loaded?.length) {
return;
}
for (const entry of loaded) {
const payload = getUrlCustomizationModulePayload(entry.module);
if (payload?.global) {
this.setCustomizations(payload.global, CustomizationScope.Global);
}
}
}
/**
* Collects the phase blocks for `phase` from every source, in apply order:
* the structured app config first, then each resolved URL module in load
* order. Used by {@link applyModeCustomizations}; the simpler `bootstrap` /
* `global` phases go through {@link _applyPhase}.
*/
private _collectPhaseBlocks(phase: keyof PhasedCustomizationConfig): PhasedCustomizationConfig[] {
const blocks: PhasedCustomizationConfig[] = [];
const phased = this._getCustomizationConfig().phased;
if (phased && phased[phase] !== undefined) {
blocks.push(phased);
}
for (const entry of this._resolvedUrlModules) {
const payload = getUrlCustomizationModulePayload(entry.module);
if (payload && payload[phase] !== undefined) {
blocks.push(payload);
}
}
return blocks;
}
/** Applies a Global-scoped phase (`bootstrap` / `global`) from all sources. */
private _applyPhase(phase: 'bootstrap' | 'global'): void {
for (const block of this._collectPhaseBlocks(phase)) {
const value = block[phase] as CustomizationPhaseInput | undefined;
if (value) {
this.setCustomizations(value, CustomizationScope.Global);
}
}
}
/**
* Internal method to set a single customization
*/
private _setCustomization(
customizationId: string,
customization: Customization,
scope: CustomizationScope = CustomizationScope.Mode
): void {
// if (typeof customization === 'string') {
// const extensionValue = this._findExtensionValue(customization);
// customization = extensionValue.value;
// }
switch (scope) {
case CustomizationScope.Global:
this.setGlobalCustomization(customizationId, customization);
break;
case CustomizationScope.Mode:
this.setModeCustomization(customizationId, customization);
break;
case CustomizationScope.Default:
this.setDefaultCustomization(customizationId, customization);
break;
default:
throw new Error(`Invalid customization scope: ${scope}`);
}
}
/**
*
* Sets a mode-specific customization.
*
* This method allows you to define or update a customization that applies only to the current mode.
* Mode customizations are temporary and isolated, reset whenever a mode changes.
*
* @param customizationId - The unique identifier for the customization.
* @param customization - The customization object containing the desired settings.
*/
private setModeCustomization(customizationId: string, customization: Customization): void {
const defaultCustomization = this.defaultCustomizations.get(customizationId);
const modeCustomization = this.modeCustomizations.get(customizationId);
const globCustomization = this.globalCustomizations.get(customizationId);
const sourceCustomization =
modeCustomization || this._cloneIfNeeded(globCustomization) || defaultCustomization;
const result = this._update(sourceCustomization, customization);
this.modeCustomizations.set(customizationId, result);
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.MODE_CUSTOMIZATION_MODIFIED, {
buttons: this.modeCustomizations,
button: this.modeCustomizations.get(customizationId),
});
}
private setGlobalCustomization(id: string, value: Customization): void {
const defaultCustomization = this.defaultCustomizations.get(id);
const globCustomization = this.globalCustomizations.get(id);
const sourceCustomization = this._cloneIfNeeded(globCustomization) || defaultCustomization;
this.globalCustomizations.set(id, this._update(sourceCustomization, value));
this.transformedCustomizations.clear();
this._broadcastEvent(this.EVENTS.GLOBAL_CUSTOMIZATION_MODIFIED, {
buttons: this.globalCustomizations,
button: this.globalCustomizations.get(id),
});
}
private setDefaultCustomization(id: string, value: Customization): void {
// There are two inits now, without a clear between them, so we can't warn about existing defaults
// if (this.defaultCustomizations.has(id)) {
// console.warn(`Trying to update existing default for customization ${id}`);
// }
this.transformedCustomizations.clear();
const sourceCustomization = this.defaultCustomizations.get(id);
this.defaultCustomizations.set(id, this._update(sourceCustomization, value));
this._broadcastEvent(this.EVENTS.DEFAULT_CUSTOMIZATION_MODIFIED, {
buttons: this.defaultCustomizations,
button: this.defaultCustomizations.get(id),
});
}
private _findExtensionValue(value: string) {
const entry = this.extensionManager.getModuleEntry(value);
return entry as { value: Customization };
}
/**
* Uses immutability-helper to apply the user's commands (e.g. $set, $push, $apply, etc.)
* Takes into account the 'mergeType' if it's explicitly 'Replace'; otherwise does a normal update.
*/
private _update(oldValue: Customization | undefined, newValue: Customization): Customization {
if (!oldValue) {
oldValue = undefined;
}
// Use immutability-helper to apply the commands
// if $ is not part of the value in the json string, then we just return the newValue
if (!hasDollarKey(newValue)) {
return newValue;
}
const result = update(oldValue, newValue);
return result;
}
private _cloneIfNeeded(value: any) {
// If it's null/undefined or not an object, return as is
if (!value || typeof value !== 'object') {
return value;
}
// If it's an array, create a shallow copy
if (Array.isArray(value)) {
return [...value];
}
// Otherwise create a shallow copy of the object
return { ...value };
}
}
/** Add custom $filter command */
@@ -523,6 +1070,48 @@ extend('$filter', (query, original) => {
return deepFilter(original, query);
});
const PHASE_CONFIG_KEYS: Array<keyof PhasedCustomizationConfig> = [
'requires',
'bootstrap',
'global',
'mode',
];
/**
* Normalizes `appConfig.customizationService` into either:
* - `{ phased }` — the structured, phase-tagged config, detected by
* the presence of any of `requires` / `bootstrap`
* / `global` / `mode`; or
* - `{ legacyReferences }` — the legacy array / object-map form, which is
* added (Global scope) during `init()` exactly as
* before.
*/
export function normalizeCustomizationConfig(configuration: unknown): {
phased?: PhasedCustomizationConfig;
legacyReferences?: unknown;
} {
if (!configuration) {
return {};
}
if (Array.isArray(configuration)) {
return { legacyReferences: configuration };
}
if (typeof configuration === 'object') {
const isPhased = PHASE_CONFIG_KEYS.some(key => key in (configuration as object));
if (isPhased) {
const phased: PhasedCustomizationConfig = {};
for (const key of PHASE_CONFIG_KEYS) {
if (key in (configuration as object)) {
(phased as Record<string, unknown>)[key] = (configuration as Record<string, unknown>)[key];
}
}
return { phased };
}
return { legacyReferences: configuration };
}
return {};
}
function hasDollarKey(value) {
if (Array.isArray(value)) {
for (const item of value) {
@@ -0,0 +1,49 @@
import {
CUSTOMIZATION_URL_PREFIXES_KEY,
customizationUrlDefaults,
DEFAULT_PREFIX,
} from './customizationUrlDefaults';
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
import { getUrlCustomizationModulePayload } from './getUrlCustomizationModulePayload';
import {
parseCustomizationParams,
validateCustomizationRequests,
normalizeCustomizationValue,
} from './validate';
import type { ValidatedCustomization, ValidationResult } from './validate';
import { resolveCustomizationUrl } from './resolve';
/**
* Builds the `?customization=` policy from the **app config** property
* `customizationUrlPrefixes` (read off `extensionManager.appConfig`). This is
* intentionally not a customization: customizations can be loaded from the URL,
* so letting one define prefixes would let it widen its own allowlist. When the
* property is absent the policy has no prefixes and the feature is off.
*/
export function getCustomizationUrlPolicy(customizationService: any): CustomizationUrlPolicy {
const prefixes =
customizationService?.extensionManager?.appConfig?.[CUSTOMIZATION_URL_PREFIXES_KEY];
if (prefixes && typeof prefixes === 'object') {
return { prefixes };
}
return customizationUrlDefaults;
}
export {
CUSTOMIZATION_URL_PREFIXES_KEY,
customizationUrlDefaults,
DEFAULT_PREFIX,
getUrlCustomizationModulePayload,
parseCustomizationParams,
validateCustomizationRequests,
normalizeCustomizationValue,
resolveCustomizationUrl,
};
export type {
CustomizationUrlPolicy,
ValidatedCustomization,
ValidationResult,
};
export type { CustomizationModule, LoadedCustomization, LoadOptions } from './customizationUrlTypes';
@@ -0,0 +1,42 @@
/**
* Policy for the `?customization=` URL query parameter.
*
* The effective policy comes from the **app config** property
* `customizationUrlPrefixes` (NOT a customization — a customization must never
* be able to widen its own allowlist). When that property is absent the policy
* has no prefixes, so the feature is **off by default**: any `?customization=`
* value is rejected because its prefix is not configured.
*
* Shape:
* - prefixes: map of prefix -> base URL used to resolve a customization value
* to a fetched `.jsonc` data file. The `default` prefix (no slashes) is used
* for values with no leading slash; every other prefix must start and end
* with a slash (e.g. `/remote/`) and is matched against the leading
* `/segment/` of the value.
*
* Example app config:
* window.config = {
* customizationUrlPrefixes: {
* default: './customizations/',
* '/remote/': 'https://cdn.example.com/ohif-customizations/',
* },
* };
*/
export interface CustomizationUrlPolicy {
prefixes: Record<string, string>;
}
/**
* App config property name holding the prefix allowlist. Read directly off
* `appConfig` — deliberately not a customization key.
*/
export const CUSTOMIZATION_URL_PREFIXES_KEY = 'customizationUrlPrefixes';
export const DEFAULT_PREFIX = 'default';
/** Off by default: no prefixes are allowed until the app config configures them. */
export const customizationUrlDefaults: CustomizationUrlPolicy = {
prefixes: {},
};
export default customizationUrlDefaults;
@@ -0,0 +1,56 @@
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
import type { ValidatedCustomization } from './validate';
/**
* The value accepted by any single customization phase block. It is whatever
* {@link CustomizationService.setCustomizations} accepts:
* - an object map of `customizationId -> customization` (with optional
* immutability-helper commands like `$set` / `$apply` / `$splice`), or
* - an array that mixes string references (extension module ids resolved via
* the ExtensionManager) and inline object maps.
*/
export type CustomizationPhaseInput = string[] | Record<string, any>;
/**
* Mode-phase customizations, keyed by mode. The reserved `*` key (see
* `GENERAL_MODE_KEY`) is the "general" block applied to every mode FIRST; any
* other key is matched against the entered mode's `id` / `routeName` and applied
* AFTER the general block, so a single mode can override the general values.
*/
export type ModePhaseCustomizations = Record<string, CustomizationPhaseInput>;
/**
* Phase-tagged customization payload. The same shape is used by:
* - URL-loaded customization modules (`?customization=` JSONC files), and
* - the `appConfig.customizationService` structured config.
*
* Each block is applied at a distinct point in the app lifecycle so ordering is
* deterministic regardless of when extensions / modes load:
* - `requires` — other URL customization modules to resolve first.
* - `bootstrap` — applied (Global scope) BEFORE extensions register.
* - `global` — applied (Global scope) AFTER extensions register / init.
* - `mode` — applied (Mode scope) on every mode enter; general first,
* then the entered mode's specific block.
*/
export interface PhasedCustomizationConfig {
requires?: string | string[];
bootstrap?: CustomizationPhaseInput;
global?: CustomizationPhaseInput;
mode?: ModePhaseCustomizations;
}
export interface CustomizationModule extends PhasedCustomizationConfig {
[key: string]: any;
}
export interface LoadedCustomization {
request: ValidatedCustomization;
module: CustomizationModule;
url: string;
}
export interface LoadOptions {
policy?: CustomizationUrlPolicy;
importFn?: (url: string) => Promise<any>;
logger?: { warn: (...args: any[]) => void; error: (...args: any[]) => void };
}
@@ -0,0 +1,41 @@
import type { CustomizationModule, PhasedCustomizationConfig } from './customizationUrlTypes';
/**
* Extracts the phase-tagged payload from a loaded customization module.
*
* A module is considered to carry a payload when it declares any of the
* lifecycle phase blocks (`bootstrap` / `global` / `mode`) or a `requires`
* edge. Returns `null` when none are present so callers can warn/skip a module
* that does nothing.
*/
export function getUrlCustomizationModulePayload(
module: CustomizationModule | null | undefined
): PhasedCustomizationConfig | null {
if (!module || typeof module !== 'object') {
return null;
}
const hasBootstrap = isPhaseInput(module.bootstrap);
const hasGlobal = isPhaseInput(module.global);
const hasMode = module.mode && typeof module.mode === 'object' && !Array.isArray(module.mode);
const hasRequires =
typeof module.requires === 'string' ||
(Array.isArray(module.requires) && module.requires.length > 0);
if (!hasBootstrap && !hasGlobal && !hasMode && !hasRequires) {
return null;
}
return {
...(hasBootstrap ? { bootstrap: module.bootstrap } : {}),
...(hasGlobal ? { global: module.global } : {}),
...(hasMode ? { mode: module.mode } : {}),
...(hasRequires ? { requires: module.requires } : {}),
};
}
/** A phase block is either an object map or an array of references. */
function isPhaseInput(value: unknown): boolean {
if (Array.isArray(value)) {
return value.length > 0;
}
return Boolean(value) && typeof value === 'object';
}
@@ -1,3 +1,11 @@
import CustomizationService from './CustomizationService';
export { GENERAL_MODE_KEY, normalizeCustomizationConfig } from './CustomizationService';
export type {
CustomizationModule,
CustomizationPhaseInput,
ModePhaseCustomizations,
PhasedCustomizationConfig,
} from './customizationUrlTypes';
export default CustomizationService;
@@ -0,0 +1,87 @@
import { resolveCustomizationUrl } from './resolve';
import type { ValidatedCustomization } from './validate';
const policy = {
prefixes: {
default: './customizations/',
remote: 'https://customizations.example.com/ohifCustomizations',
relative: '/customAssets/',
},
};
function req(prefix: string, name: string): ValidatedCustomization {
return {
raw: name,
normalized: `/${prefix}/${name}`,
prefix,
name,
};
}
describe('CustomizationService URL resolve', () => {
let originalLocation: PropertyDescriptor | undefined;
beforeAll(() => {
originalLocation = Object.getOwnPropertyDescriptor(window, 'location');
Object.defineProperty(window, 'location', {
configurable: true,
value: {
origin: 'https://viewer.example.com',
search: '',
},
});
});
afterAll(() => {
if (originalLocation) {
Object.defineProperty(window, 'location', originalLocation);
}
});
it('resolves /default/<name> against same-origin/base', () => {
const url = resolveCustomizationUrl(req('default', 'veterinaryOverlay'), policy);
expect(url.startsWith('https://viewer.example.com')).toBe(true);
expect(url.endsWith('/customizations/veterinaryOverlay.jsonc')).toBe(true);
});
it('resolves an absolute trusted prefix to its remote URL', () => {
const url = resolveCustomizationUrl(req('remote', 'veterinaryOverlay'), policy);
expect(url).toBe(
'https://customizations.example.com/ohifCustomizations/veterinaryOverlay.jsonc'
);
});
it('resolves a relative absolute-path prefix against same origin', () => {
const url = resolveCustomizationUrl(req('relative', 'foo'), policy);
expect(url).toBe('https://viewer.example.com/customAssets/foo.jsonc');
});
it('throws on unknown prefix', () => {
expect(() => resolveCustomizationUrl(req('missing', 'x'), policy)).toThrow();
});
it('throws when the name contains traversal', () => {
expect(() =>
resolveCustomizationUrl(req('default', '../escape'), policy)
).toThrow(/traversal/);
});
it('accepts names that already include .jsonc suffix', () => {
const url = resolveCustomizationUrl(req('default', 'veterinary.jsonc'), policy);
expect(url.endsWith('/customizations/veterinary.jsonc')).toBe(true);
});
it('throws when an encoded traversal would escape the base after URL normalization', () => {
// `%2e%2e` survives the literal `..` check but the URL parser collapses it to
// `..`, which would otherwise escape the prefix directory.
expect(() => resolveCustomizationUrl(req('default', '%2e%2e/secret'), policy)).toThrow(
/escapes its configured prefix/
);
});
it('throws when an encoded traversal escapes an absolute (remote) prefix', () => {
expect(() =>
resolveCustomizationUrl(req('remote', '%2e%2e/%2e%2e/evil'), policy)
).toThrow(/escapes its configured prefix/);
});
});
@@ -0,0 +1,84 @@
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
import type { ValidatedCustomization } from './validate';
const ABSOLUTE_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
// Used only to parse same-origin/relative paths into a comparable URL. Any
// host works since we compare origin + pathname against the base built the
// same way; it is never part of the returned value.
const PARSE_BASE = 'https://ohif.invalid';
function getViewerPublicUrl(): string {
if (typeof window === 'undefined') {
return '/';
}
return (window as any).PUBLIC_URL || '/';
}
/**
* Defense-in-depth containment check on the *final* resolved string. We parse
* both the resolved URL and its configured base directory with the same URL
* parser that `import()` uses — which collapses `.`/`..`/`%2e%2e` path segments
* — and require the result to stay within the base directory. Validation should
* already have rejected traversal, but parsing the final string here guarantees
* a module can never load outside its allowlisted prefix even if an earlier
* check is bypassed.
*/
function assertWithinBase(
finalUrl: string,
baseDirUrl: string,
request: ValidatedCustomization
): void {
let resolved: URL;
let baseDir: URL;
try {
resolved = new URL(finalUrl, PARSE_BASE);
baseDir = new URL(baseDirUrl, PARSE_BASE);
} catch {
throw new Error(`Customization URL could not be parsed for "${request.raw}"`);
}
const baseDirPath = baseDir.pathname.endsWith('/') ? baseDir.pathname : `${baseDir.pathname}/`;
const within = resolved.origin === baseDir.origin && resolved.pathname.startsWith(baseDirPath);
if (!within) {
throw new Error(`Customization "${request.raw}" escapes its configured prefix directory`);
}
}
export function resolveCustomizationUrl(
request: ValidatedCustomization,
policy: CustomizationUrlPolicy
): string {
const prefixes = policy.prefixes || {};
const base = prefixes[request.prefix];
if (!base) {
throw new Error(`Unknown customization prefix: ${request.prefix}`);
}
if (request.name.includes('..')) {
throw new Error(`Customization name contains traversal: ${request.name}`);
}
const fileName = request.name.endsWith('.jsonc') ? request.name : `${request.name}.jsonc`;
const baseWithSlash = base.endsWith('/') ? base : `${base}/`;
const joined = `${baseWithSlash}${fileName}`;
if (ABSOLUTE_URL_REGEX.test(base)) {
assertWithinBase(joined, baseWithSlash, request);
return joined;
}
const origin =
typeof window !== 'undefined' && window.location?.origin ? window.location.origin : '';
const publicUrl = getViewerPublicUrl();
const root = publicUrl?.startsWith('/') ? publicUrl : `/${publicUrl || ''}`;
const strip = (value: string): string =>
value.startsWith('./') ? value.slice(2) : value.startsWith('/') ? value.slice(1) : value;
const relative = strip(joined);
const relativeBase = strip(baseWithSlash);
const rootWithSlash = root.endsWith('/') ? root : `${root}/`;
const path = `${rootWithSlash}${relative}`;
const baseDirPath = `${rootWithSlash}${relativeBase}`;
const finalUrl = origin ? `${origin}${path}` : path;
assertWithinBase(finalUrl, origin ? `${origin}${baseDirPath}` : baseDirPath, request);
return finalUrl;
}
@@ -0,0 +1,160 @@
import {
parseCustomizationParams,
normalizeCustomizationValue,
validateCustomizationRequests,
} from './validate';
import { customizationUrlDefaults } from './customizationUrlDefaults';
describe('CustomizationService URL validate', () => {
describe('parseCustomizationParams', () => {
it('returns repeated and comma-delimited values flattened', () => {
const params = new URLSearchParams();
params.append('customization', 'a,b');
params.append('customization', 'c');
expect(parseCustomizationParams(params)).toEqual(['a', 'b', 'c']);
});
it('matches the parameter key case-insensitively', () => {
const params = new URLSearchParams();
params.append('Customization', 'foo');
params.append('CUSTOMIZATION', 'bar');
expect(parseCustomizationParams(params)).toEqual(['foo', 'bar']);
});
it('skips empty pieces and trims whitespace', () => {
const params = new URLSearchParams();
params.append('customization', ' a , , b ');
expect(parseCustomizationParams(params)).toEqual(['a', 'b']);
});
});
describe('normalizeCustomizationValue', () => {
it('prepends /default/ for path-relative values', () => {
expect(normalizeCustomizationValue('veterinaryOverlay')).toBe(
'/default/veterinaryOverlay'
);
});
it('preserves explicit /prefix/name forms', () => {
expect(normalizeCustomizationValue('/remote/foo')).toBe('/remote/foo');
});
it('returns null when there is no name part', () => {
expect(normalizeCustomizationValue('/onlyPrefix')).toBeNull();
expect(normalizeCustomizationValue('')).toBeNull();
});
});
describe('validateCustomizationRequests', () => {
const policy = {
...customizationUrlDefaults,
prefixes: {
default: './customizations/',
'/remote/': 'https://customizations.example.com/ohifCustomizations',
},
};
it('accepts default-prefixed names', () => {
const result = validateCustomizationRequests(['veterinary'], policy);
expect(result.rejected).toEqual([]);
expect(result.valid).toHaveLength(1);
expect(result.valid[0].normalized).toBe('/default/veterinary');
expect(result.valid[0].prefix).toBe('default');
expect(result.valid[0].name).toBe('veterinary');
});
it('accepts arbitrary logical names under a configured prefix', () => {
const result = validateCustomizationRequests(['siteTheme2026'], policy);
expect(result.rejected).toEqual([]);
expect(result.valid).toHaveLength(1);
expect(result.valid[0].name).toBe('siteTheme2026');
});
it('accepts remote-prefixed names', () => {
const result = validateCustomizationRequests(['/remote/veterinaryOverlay'], policy);
expect(result.rejected).toEqual([]);
expect(result.valid[0].prefix).toBe('/remote/');
expect(result.valid[0].name).toBe('veterinaryOverlay');
});
it('keeps multi-segment names under a slash prefix', () => {
const result = validateCustomizationRequests(['/remote/siteA/theme'], policy);
expect(result.rejected).toEqual([]);
expect(result.valid[0].prefix).toBe('/remote/');
expect(result.valid[0].name).toBe('siteA/theme');
});
it('rejects values with .. traversal', () => {
const result = validateCustomizationRequests(['../etc/passwd'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected[0].reason).toMatch(/traversal/);
});
it('rejects full URLs', () => {
const result = validateCustomizationRequests(
['http://evil.example.com/x', 'https://evil/x', '//evil/x'],
policy
);
expect(result.valid).toEqual([]);
expect(result.rejected).toHaveLength(3);
for (const r of result.rejected) {
expect(r.reason).toMatch(/full URLs/);
}
});
it('rejects unknown prefixes', () => {
const result = validateCustomizationRequests(['/missing/x'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected[0].reason).toMatch(/unknown prefix/);
});
it('rejects an explicit /default/ prefix (the default prefix has no slashes)', () => {
// The `default` prefix is only reachable by values without a leading slash.
// A leading-slash value matches `/default/`, which is not a configured key.
const result = validateCustomizationRequests(['/default/foo'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected[0].reason).toMatch(/unknown prefix/);
});
it('rejects unsafe name segments', () => {
const result = validateCustomizationRequests(['foo/./bar'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected[0].reason).toMatch(/unsafe/);
});
it('rejects percent-encoded traversal that decodes to ".."', () => {
// URLSearchParams decodes one layer, so a double-encoded `..` arrives here
// as `%2e%2e`; the WHATWG URL parser would normalize that to `..`.
const result = validateCustomizationRequests(
['foo/%2e%2e/%2e%2e/app-config', '/default/%2E%2E/secret'],
policy
);
expect(result.valid).toEqual([]);
expect(result.rejected).toHaveLength(2);
for (const r of result.rejected) {
expect(r.reason).toMatch(/traversal/);
}
});
it('rejects percent-encoded slashes that would inject extra path segments', () => {
const result = validateCustomizationRequests(['/default/foo%2f..%2fbar'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected[0].reason).toMatch(/traversal/);
});
it('rejects encoded full URLs', () => {
const result = validateCustomizationRequests(['https%3A%2F%2Fevil.example.com/x'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected[0].reason).toMatch(/full URLs/);
});
it('rejects malformed percent-encoding', () => {
const result = validateCustomizationRequests(['/default/foo%zz', '/default/bar%'], policy);
expect(result.valid).toEqual([]);
expect(result.rejected).toHaveLength(2);
for (const r of result.rejected) {
expect(r.reason).toMatch(/percent-encoding/);
}
});
});
});
@@ -0,0 +1,177 @@
import type { CustomizationUrlPolicy } from './customizationUrlDefaults';
import { DEFAULT_PREFIX } from './customizationUrlDefaults';
export interface ValidatedCustomization {
raw: string;
normalized: string;
prefix: string;
name: string;
}
export interface ValidationResult {
valid: ValidatedCustomization[];
rejected: { raw: string; reason: string }[];
}
const FULL_URL_REGEX = /^([a-z][a-z0-9+.-]*:|\/\/)/i;
/**
* Fully percent-decode a customization value so validation runs against the
* string the URL parser (used by `import()`) ultimately resolves — not the
* escaped form. This is what blocks encoded traversal such as `%2e%2e`
* (decodes to `..`) or `%2f` (decodes to `/`): the WHATWG URL parser treats
* `%2e%2e` as a `..` path segment and would otherwise let a value escape the
* configured prefix directory.
*
* Returns the stable decoded string, or `null` when the value is malformed
* (invalid escape), still contains a literal `%` after decoding, or nests
* encoding too deeply to settle. Legitimate customization names are plain
* identifiers/paths and never need percent-encoding, so rejecting these is safe.
*/
export function fullyDecodeValue(value: string): string | null {
let current = value;
for (let i = 0; i < 5; i++) {
if (!current.includes('%')) {
return current;
}
let decoded: string;
try {
decoded = decodeURIComponent(current);
} catch {
return null;
}
if (decoded === current) {
// Stable but still contains a literal `%`; treat as unsafe.
return null;
}
current = decoded;
}
return null;
}
export function parseCustomizationParams(
params: URLSearchParams,
paramKey = 'customization'
): string[] {
const out: string[] = [];
const keys = Array.from(new Set(params.keys()));
for (const key of keys) {
if (key.toLowerCase() !== paramKey.toLowerCase()) {
continue;
}
for (const raw of params.getAll(key)) {
if (!raw) continue;
for (const piece of raw.split(',')) {
const trimmed = piece.trim();
if (trimmed) {
out.push(trimmed);
}
}
}
}
return out;
}
/**
* Splits a customization value into its prefix and name.
*
* - A value with **no leading slash** uses the special `default` prefix and the
* whole value is the name (e.g. `abc/def` -> prefix `default`, name `abc/def`).
* - A value with a **leading slash** is matched against its first `/segment/`,
* which becomes the prefix (slashes included), and the remainder is the name
* (e.g. `/remote/def` -> prefix `/remote/`, name `def`). This is why every
* prefix other than `default` must start and end with a slash.
*
* Returns `null` when there is no usable name part.
*/
export function splitPrefixAndName(
value: string
): { prefix: string; name: string } | null {
if (!value) {
return null;
}
if (!value.startsWith('/')) {
return { prefix: DEFAULT_PREFIX, name: value };
}
const rest = value.slice(1);
const slashIdx = rest.indexOf('/');
if (slashIdx <= 0) {
return null;
}
const segment = rest.slice(0, slashIdx);
const name = rest.slice(slashIdx + 1);
if (!segment || !name) {
return null;
}
return { prefix: `/${segment}/`, name };
}
function buildNormalized(prefix: string, name: string): string {
return prefix === DEFAULT_PREFIX ? `/${DEFAULT_PREFIX}/${name}` : `${prefix}${name}`;
}
export function normalizeCustomizationValue(value: string): string | null {
if (!value) {
return null;
}
const split = splitPrefixAndName(value.trim());
if (!split) {
return null;
}
return buildNormalized(split.prefix, split.name);
}
function hasUnsafeNameSegments(name: string): boolean {
if (!name.trim()) {
return true;
}
return name.split('/').some(seg => !seg || seg === '.' || seg === '..' || seg.includes('..'));
}
export function validateCustomizationRequests(
raws: string[],
policy: CustomizationUrlPolicy
): ValidationResult {
const result: ValidationResult = { valid: [], rejected: [] };
const prefixes = policy.prefixes || {};
for (const raw of raws) {
// Validate against the fully decoded value so percent-encoded traversal
// (e.g. `%2e%2e`, `%2f`) cannot slip past the checks below and escape the
// configured prefix once the URL parser normalizes the import path.
const decoded = fullyDecodeValue(raw);
if (decoded === null) {
result.rejected.push({ raw, reason: 'contains malformed or unsupported percent-encoding' });
continue;
}
if (decoded.includes('..')) {
result.rejected.push({ raw, reason: 'contains ".." traversal segment' });
continue;
}
if (FULL_URL_REGEX.test(decoded)) {
result.rejected.push({ raw, reason: 'full URLs are not permitted' });
continue;
}
const split = splitPrefixAndName(decoded.trim());
if (!split) {
result.rejected.push({ raw, reason: 'could not be split into a prefix and name' });
continue;
}
const { prefix, name } = split;
if (!Object.prototype.hasOwnProperty.call(prefixes, prefix)) {
result.rejected.push({ raw, reason: `unknown prefix "${prefix}"` });
continue;
}
if (hasUnsafeNameSegments(name)) {
result.rejected.push({ raw, reason: 'invalid or unsafe customization name' });
continue;
}
result.valid.push({ raw, normalized: buildNormalized(prefix, name), prefix, name });
}
return result;
}
@@ -191,7 +191,7 @@ export class MultiMonitorService {
* Try moving the screen to the correct location - this will only work with
* screens opened with openWindow containing no more than 1 tab.
*/
public async onModeEnter() {
public onModeEnter() {
this.setBasePath();
if (
+2
View File
@@ -21,6 +21,8 @@ import { MultiMonitorService } from './MultiMonitorService';
import type Services from '../types/Services';
export * from './CustomizationService/customizationUrl';
export {
Services,
MeasurementService,
+24 -1
View File
@@ -1,6 +1,7 @@
/* eslint-disable @typescript-eslint/no-namespace */
import HangingProtocolServiceType from '../services/HangingProtocolService';
import CustomizationServiceType from '../services/CustomizationService';
import type { PhasedCustomizationConfig } from '../services/CustomizationService';
import MeasurementServiceType from '../services/MeasurementService';
import ViewportGridServiceType from '../services/ViewportGridService';
import ToolbarServiceType from '../services/ToolBarService';
@@ -85,7 +86,29 @@ declare global {
export interface Config {
studyBrowserMode?: 'all' | 'primary';
routerBasename?: string;
customizationService?: CustomizationServiceType;
/**
* Startup customizations. Two forms are accepted:
*
* - **Legacy**: an array of references / object map applied to the Global
* scope during `init()` (e.g.
* `['@ohif/extension-default.customizationModule.datasources']`).
* - **Phase-tagged** ({@link PhasedCustomizationConfig}): an object with
* any of `requires` / `bootstrap` / `global` / `mode`. `requires`
* pulls in URL-style customization data files; `bootstrap` /
* `global` apply (Global scope) before / after extensions register; and
* `mode` applies (Mode scope) per mode on entry — the `*` block to all
* modes first, then a block keyed by the mode id / routeName.
*/
customizationService?: PhasedCustomizationConfig | string[] | Record<string, unknown>;
/**
* Allowlist of prefixes for the `?customization=` URL parameter, mapping a
* prefix to a base URL/path. The `default` prefix (no slashes) handles
* values with no leading slash; every other prefix must start and end with
* a slash (e.g. `/remote/`). Intentionally an app-config property and not a
* customization so a URL-loaded customization cannot widen its own
* allowlist. Absent (the default) means `?customization=` is disabled.
*/
customizationUrlPrefixes?: Record<string, string>;
extensions?: string[];
modes?: string[];
experimentalStudyBrowserSort?: boolean;
+22
View File
@@ -0,0 +1,22 @@
/**
* Formats values for safe text display.
*/
export default function formatValue(value) {
if (value == null) {
return null;
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'object' && typeof value.Alphabetic === 'string') {
return value.Alphabetic;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return String(value);
}
return null;
}
@@ -0,0 +1,40 @@
import formatValue from './formatValue';
describe('formatValue', () => {
it('returns null for null or undefined', () => {
expect(formatValue(null)).toBeNull();
expect(formatValue(undefined)).toBeNull();
});
it('returns strings unchanged, including empty strings', () => {
expect(formatValue('horse')).toBe('horse');
expect(formatValue('')).toBe('');
});
it('extracts the Alphabetic component of a PersonName object', () => {
expect(formatValue({ Alphabetic: 'Doe^John' })).toBe('Doe^John');
});
it('coerces numbers to strings, including zero', () => {
expect(formatValue(0)).toBe('0');
expect(formatValue(42)).toBe('42');
});
it('coerces booleans to strings', () => {
expect(formatValue(true)).toBe('true');
expect(formatValue(false)).toBe('false');
});
it('returns null for plain objects so they never render as [object Object]', () => {
expect(formatValue({})).toBeNull();
expect(formatValue({ foo: 'bar' })).toBeNull();
});
it('returns null for objects whose Alphabetic is not a string', () => {
expect(formatValue({ Alphabetic: 123 })).toBeNull();
});
it('returns null for arrays', () => {
expect(formatValue(['a', 'b'])).toBeNull();
});
});
+3
View File
@@ -15,6 +15,7 @@ import isDicomUid from './isDicomUid';
import formatDate from './formatDate';
import formatTime from './formatTime';
import formatPN from './formatPN';
import formatValue from './formatValue';
import generateAcceptHeader from './generateAcceptHeader';
import resolveObjectPath from './resolveObjectPath';
import hierarchicalListUtils from './hierarchicalListUtils';
@@ -73,6 +74,7 @@ const utils = {
formatDate,
formatTime,
formatPN,
formatValue,
b64toBlob,
urlUtil,
imageIdToURI,
@@ -117,6 +119,7 @@ export {
absoluteUrl,
sortBy,
formatDate,
formatValue,
writeScript,
b64toBlob,
urlUtil,