Skip to content
Open
Show file tree
Hide file tree
Changes from 27 commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
bc9c4df
feat: Middleware plugin infrastructure for widget chaining
vbabich Apr 10, 2026
4f91e0a
Cleanup
vbabich Apr 13, 2026
a172040
Cleanup
vbabich Apr 13, 2026
6ed5e6f
Cleanup
vbabich Apr 13, 2026
bf3716e
Add warning for duplicate base plugins
vbabich Apr 13, 2026
7b774ac
Fix review issues
vbabich Apr 14, 2026
91565b0
Verbose debug logging
vbabich Apr 17, 2026
00e45e7
Fix eslint
vbabich Apr 20, 2026
c5bb6a2
Plugin chaining - dynamic dependencies
vbabich Apr 21, 2026
0b773bc
Allow cross-plugin imports
vbabich Apr 23, 2026
f21c0dd
Update .md
vbabich Apr 23, 2026
8442cd4
Remove unnecessary changes
vbabich Apr 23, 2026
c1d74d6
PluginUtils deduplication phase 1
vbabich Apr 24, 2026
366cc20
Implement plugin dependecies, topological sorting
vbabich Apr 24, 2026
4c735d8
Update doc, comments
vbabich Apr 24, 2026
403d166
Self-review, cleanup
vbabich Apr 24, 2026
e610a4b
Address review comment
vbabich Apr 24, 2026
ca4ca14
Merge remote-tracking branch 'origin/main' into middleware-plugin-infra
vbabich Apr 24, 2026
44503a1
Change middleware flag to new plugin type
vbabich Apr 24, 2026
4b2f735
Delete feature doc
vbabich Apr 24, 2026
9a4c442
Fix types, package-lock
vbabich Apr 27, 2026
28a771e
Extract topological sort into a separate util
vbabich Apr 28, 2026
aae7c6b
Eslint
vbabich Apr 28, 2026
2e61e9b
Merge remote-tracking branch 'origin/main' into middleware-plugin-infra
vbabich Apr 30, 2026
b8d4bba
Move sort plugins to parent
vbabich Apr 30, 2026
54d0b92
Address self-review
vbabich Apr 30, 2026
4be2fd2
Merge remote-tracking branch 'origin/main' into middleware-plugin-infra
vbabich Apr 30, 2026
5b3e45f
Address review comment
vbabich May 4, 2026
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
402 changes: 258 additions & 144 deletions packages/app-utils/src/plugins/PluginUtils.test.ts

Large diffs are not rendered by default.

117 changes: 29 additions & 88 deletions packages/app-utils/src/plugins/PluginUtils.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,23 +9,15 @@ import {
type Plugin,
PluginType,
isLegacyAuthPlugin,
isLegacyPlugin,
type PluginModule,
isPlugin,
isMultiPlugin,
processLoadedModule,
sortPluginsByDependency,
type PluginManifest,
} from '@deephaven/plugin';
import loadRemoteModule from './loadRemoteModule';
import { resolve } from './remote-component.config';

const log = Log.module('@deephaven/app-utils.PluginUtils');

export type PluginManifestPluginInfo = {
name: string;
main: string;
version: string;
};

export type PluginManifest = { plugins: PluginManifestPluginInfo[] };

/**
* Imports a commonjs plugin module from the provided URL
* @param pluginUrl The URL of the plugin to load
Expand Down Expand Up @@ -55,56 +47,11 @@ export async function loadJson(jsonUrl: string): Promise<PluginManifest> {
}
}

function hasDefaultExport(value: unknown): value is { default: Plugin } {
return (
typeof value === 'object' &&
value != null &&
typeof (value as { default?: unknown }).default === 'object'
);
}

export function getPluginModuleValue(
value: LegacyPlugin | Plugin | { default: Plugin }
): PluginModule | null {
// TypeScript builds CJS default exports differently depending on
// whether there are also named exports. If the default is the only
// export, it will be the value. If there are also named exports,
// it will be assigned to the `default` property on the value.
if (isPlugin(value)) {
return value;
}
if (hasDefaultExport(value) && isPlugin(value.default)) {
return value.default;
}
if (isLegacyPlugin(value)) {
return value;
}
return null;
}

/**
* Register a plugin in the plugin map, logging a warning if a plugin with the same name already exists.
* @param pluginMap The plugin map to register the plugin in
* @param name The name to register the plugin under
* @param plugin The plugin to register
* @param version Optional version to attach to the plugin
*/
function registerPlugin(
pluginMap: PluginModuleMap,
name: string,
plugin: PluginModule,
version?: string
): void {
if (pluginMap.has(name)) {
log.warn(
`Plugin '${name}' is already registered. The existing plugin will be replaced.`
);
}
pluginMap.set(name, { ...plugin, version });
}

/**
* Load all plugin modules available based on the manifest file at the provided base URL
* Load all plugin modules available based on the manifest file at the provided base URL.
* Plugins are loaded sequentially so that each plugin's exports are registered
* in the module resolve map before subsequent plugins load. This enables
* cross-plugin imports via standard import statements.
* @param modulePluginsUrl The base URL of the module plugins to load
* @returns A map from the name of the plugin to the plugin module that was loaded
*/
Expand All @@ -120,36 +67,30 @@ export async function loadModulePlugins(
}

log.debug('Plugin manifest loaded:', manifest);
const pluginPromises: Promise<LegacyPlugin | { default: Plugin }>[] = [];
for (let i = 0; i < manifest.plugins.length; i += 1) {
const { name, main } = manifest.plugins[i];
const pluginMainUrl = `${modulePluginsUrl}/${name}/${main}`;
pluginPromises.push(loadModulePlugin(pluginMainUrl));
}

const pluginModules = await Promise.allSettled(pluginPromises);
const sortedPlugins = sortPluginsByDependency(manifest.plugins);

const pluginMap: PluginModuleMap = new Map();
for (let i = 0; i < pluginModules.length; i += 1) {
const module = pluginModules[i];
const { name, version } = manifest.plugins[i];
if (module.status === 'fulfilled') {
const moduleValue = getPluginModuleValue(module.value);
if (moduleValue == null) {
log.error(`Plugin '${name}' is missing an exported value.`);
} else if (isMultiPlugin(moduleValue)) {
// Flatten MultiPlugin: register each inner plugin by its own name
log.debug(
`MultiPlugin '${name}' contains ${moduleValue.plugins.length} plugins`
);
moduleValue.plugins.forEach(innerPlugin => {
registerPlugin(pluginMap, innerPlugin.name, innerPlugin, version);
});
} else {
registerPlugin(pluginMap, name, moduleValue, version);
}
} else {
log.error(`Unable to load plugin '${name}'`, module.reason);

// Load plugins sequentially so each plugin's exports are available
// to subsequently loaded plugins via import
for (let i = 0; i < sortedPlugins.length; i += 1) {
const { name, main, version, package: packageName } = sortedPlugins[i];
const pluginMainUrl = `${modulePluginsUrl}/${name}/${main}`;
try {
// eslint-disable-next-line no-await-in-loop
const pluginExports = await loadModulePlugin(pluginMainUrl);

processLoadedModule(
pluginMap,
resolve,
pluginExports,
name,
packageName,
version
);
} catch (e) {
log.error(`Unable to load plugin '${name}'`, e);
}
}
log.info('Plugins loaded:', pluginMap);
Expand Down
2 changes: 1 addition & 1 deletion packages/app-utils/src/plugins/remote-component.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import * as DeephavenReactHooks from '@deephaven/react-hooks';
import * as DeephavenPlugin from '@deephaven/plugin';

// eslint-disable-next-line import/prefer-default-export
export const resolve = {
export const resolve: Record<string, unknown> = {
react,
'react-dom': ReactDOM,
redux,
Expand Down
Loading
Loading