Skip to content

Commit 5116dfb

Browse files
authored
feat: DH-21476: Middleware plugin infrastructure for widget chaining (#2660)
# Middleware Plugin Infrastructure for Widget Chaining ## Summary Adds middleware plugin support so plugins can wrap existing widgets without replacing them, cross-plugin dependency loading via manifest `package`/`dependencies` fields, and shared plugin-loading utilities in `@deephaven/plugin`. ## What Changed **Middleware chaining** — New `WidgetMiddlewarePlugin` type, identified by its own `PluginType.MIDDLEWARE_PLUGIN` discriminator. Middleware receives a `Component` prop and wraps the next layer. Multiple middleware compose in registration order. Applied in both `WidgetLoaderPlugin` (dashboard panels) and `WidgetView` (inline widgets) via `createChainedComponent`/`createChainedPanelComponent`. The chaining functions automatically filter middleware by `supportedTypes` at render time, so middleware only activates for matching widget types. **Cross-plugin dependencies** — Manifest entries can declare `package` (makes the plugin's exports available to other plugins) and `dependencies` (topological sort so deps load first). Plugins load sequentially so each plugin's exports are available to subsequent plugins via standard `import` statements. **Shared loading utilities** — `getPluginModuleValue`, `registerPlugin`, `processLoadedModule`, `sortPluginsByDependency`, and manifest types moved from `@deephaven/app-utils` into `@deephaven/plugin`. Both web-client-ui and gplus consume these instead of duplicating the logic. ~80 lines removed from app-utils. **Behavior change — duplicate plugin names.** When two plugins in the manifest are loaded under the same name, the shared `registerPlugin` now **skips the duplicate** and keeps the first registration (with a warning). Previously the duplicate **replaced** the existing entry. This applies to plugin-name collisions in the plugin map; widget-type resolution in `WidgetLoaderPlugin` is unaffected (last base plugin still wins for a given widget type). ## Key Files | File | Change | |---|---| | `packages/plugin/src/PluginTypes.ts` | Middleware types, type guard | | `packages/plugin/src/PluginUtils.tsx` | Chaining functions, loader utils, manifest types | | `packages/plugin/src/sortPluginsByDependency.ts` | Topological sort for plugin dependencies | | `packages/plugin/src/WidgetView.tsx` | Middleware-aware inline rendering | | `packages/dashboard-core-plugins/src/WidgetLoaderPlugin.tsx` | Middleware-aware panel loading | | `packages/app-utils/src/plugins/PluginUtils.tsx` | Simplified to use shared utils | | `packages/plugin/docs/middleware-architecture.md` | Architecture reference |
1 parent ada1e36 commit 5116dfb

13 files changed

Lines changed: 2312 additions & 267 deletions

packages/app-utils/src/plugins/PluginUtils.test.ts

Lines changed: 258 additions & 144 deletions
Large diffs are not rendered by default.

packages/app-utils/src/plugins/PluginUtils.tsx renamed to packages/app-utils/src/plugins/PluginUtils.ts

Lines changed: 44 additions & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -9,22 +9,31 @@ import {
99
type Plugin,
1010
PluginType,
1111
isLegacyAuthPlugin,
12-
isLegacyPlugin,
13-
type PluginModule,
14-
isPlugin,
15-
isMultiPlugin,
12+
processLoadedModule,
13+
sortPluginsByDependency,
14+
type PluginManifest,
15+
type PluginManifestPluginInfo,
16+
getPluginModuleValue,
1617
} from '@deephaven/plugin';
1718
import loadRemoteModule from './loadRemoteModule';
19+
import { resolve } from './remote-component.config';
1820

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

21-
export type PluginManifestPluginInfo = {
22-
name: string;
23-
main: string;
24-
version: string;
25-
};
23+
/**
24+
* @deprecated Import from `@deephaven/plugin` instead.
25+
*/
26+
export type { PluginManifestPluginInfo };
2627

27-
export type PluginManifest = { plugins: PluginManifestPluginInfo[] };
28+
/**
29+
* @deprecated Import from `@deephaven/plugin` instead.
30+
*/
31+
export type { PluginManifest };
32+
33+
/**
34+
* @deprecated Import from `@deephaven/plugin` instead.
35+
*/
36+
export { getPluginModuleValue };
2837

2938
/**
3039
* Imports a commonjs plugin module from the provided URL
@@ -55,56 +64,11 @@ export async function loadJson(jsonUrl: string): Promise<PluginManifest> {
5564
}
5665
}
5766

58-
function hasDefaultExport(value: unknown): value is { default: Plugin } {
59-
return (
60-
typeof value === 'object' &&
61-
value != null &&
62-
typeof (value as { default?: unknown }).default === 'object'
63-
);
64-
}
65-
66-
export function getPluginModuleValue(
67-
value: LegacyPlugin | Plugin | { default: Plugin }
68-
): PluginModule | null {
69-
// TypeScript builds CJS default exports differently depending on
70-
// whether there are also named exports. If the default is the only
71-
// export, it will be the value. If there are also named exports,
72-
// it will be assigned to the `default` property on the value.
73-
if (isPlugin(value)) {
74-
return value;
75-
}
76-
if (hasDefaultExport(value) && isPlugin(value.default)) {
77-
return value.default;
78-
}
79-
if (isLegacyPlugin(value)) {
80-
return value;
81-
}
82-
return null;
83-
}
84-
8567
/**
86-
* Register a plugin in the plugin map, logging a warning if a plugin with the same name already exists.
87-
* @param pluginMap The plugin map to register the plugin in
88-
* @param name The name to register the plugin under
89-
* @param plugin The plugin to register
90-
* @param version Optional version to attach to the plugin
91-
*/
92-
function registerPlugin(
93-
pluginMap: PluginModuleMap,
94-
name: string,
95-
plugin: PluginModule,
96-
version?: string
97-
): void {
98-
if (pluginMap.has(name)) {
99-
log.warn(
100-
`Plugin '${name}' is already registered. The existing plugin will be replaced.`
101-
);
102-
}
103-
pluginMap.set(name, { ...plugin, version });
104-
}
105-
106-
/**
107-
* Load all plugin modules available based on the manifest file at the provided base URL
68+
* Load all plugin modules available based on the manifest file at the provided base URL.
69+
* Plugins are loaded sequentially so that each plugin's exports are registered
70+
* in the module resolve map before subsequent plugins load. This enables
71+
* cross-plugin imports via standard import statements.
10872
* @param modulePluginsUrl The base URL of the module plugins to load
10973
* @returns A map from the name of the plugin to the plugin module that was loaded
11074
*/
@@ -120,36 +84,30 @@ export async function loadModulePlugins(
12084
}
12185

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

130-
const pluginModules = await Promise.allSettled(pluginPromises);
88+
const sortedPlugins = sortPluginsByDependency(manifest.plugins);
13189

13290
const pluginMap: PluginModuleMap = new Map();
133-
for (let i = 0; i < pluginModules.length; i += 1) {
134-
const module = pluginModules[i];
135-
const { name, version } = manifest.plugins[i];
136-
if (module.status === 'fulfilled') {
137-
const moduleValue = getPluginModuleValue(module.value);
138-
if (moduleValue == null) {
139-
log.error(`Plugin '${name}' is missing an exported value.`);
140-
} else if (isMultiPlugin(moduleValue)) {
141-
// Flatten MultiPlugin: register each inner plugin by its own name
142-
log.debug(
143-
`MultiPlugin '${name}' contains ${moduleValue.plugins.length} plugins`
144-
);
145-
moduleValue.plugins.forEach(innerPlugin => {
146-
registerPlugin(pluginMap, innerPlugin.name, innerPlugin, version);
147-
});
148-
} else {
149-
registerPlugin(pluginMap, name, moduleValue, version);
150-
}
151-
} else {
152-
log.error(`Unable to load plugin '${name}'`, module.reason);
91+
92+
// Load plugins sequentially so each plugin's exports are available
93+
// to subsequently loaded plugins via import
94+
for (let i = 0; i < sortedPlugins.length; i += 1) {
95+
const { name, main, version, package: packageName } = sortedPlugins[i];
96+
const pluginMainUrl = `${modulePluginsUrl}/${name}/${main}`;
97+
try {
98+
// eslint-disable-next-line no-await-in-loop
99+
const pluginExports = await loadModulePlugin(pluginMainUrl);
100+
101+
processLoadedModule(
102+
pluginMap,
103+
resolve,
104+
pluginExports,
105+
name,
106+
packageName,
107+
version
108+
);
109+
} catch (e) {
110+
log.error(`Unable to load plugin '${name}'`, e);
153111
}
154112
}
155113
log.info('Plugins loaded:', pluginMap);

packages/app-utils/src/plugins/remote-component.config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import * as DeephavenReactHooks from '@deephaven/react-hooks';
2525
import * as DeephavenPlugin from '@deephaven/plugin';
2626

2727
// eslint-disable-next-line import/prefer-default-export
28-
export const resolve = {
28+
export const resolve: Record<string, unknown> = {
2929
react,
3030
'react-dom': ReactDOM,
3131
redux,

0 commit comments

Comments
 (0)