Skip to content

Commit 0716d20

Browse files
Copilothotlong
andcommitted
Add PluginSystem and LazyPluginLoader implementation
Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
1 parent c8a49db commit 0716d20

6 files changed

Lines changed: 439 additions & 0 deletions

File tree

packages/core/src/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
export * from './types';
1010
export * from './registry/Registry';
11+
export * from './registry/PluginSystem';
1112
export * from './validation';
1213
export * from './builder/schema-builder';
1314
export * from './utils/filter-converter';
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import type { Registry } from './Registry';
10+
11+
export interface PluginDefinition {
12+
name: string;
13+
version: string;
14+
dependencies?: string[]; // Dependencies on other plugins
15+
peerDependencies?: string[]; // Peer dependencies
16+
register: (registry: Registry) => void;
17+
onLoad?: () => void | Promise<void>; // Lifecycle hook: called after registration
18+
onUnload?: () => void | Promise<void>; // Lifecycle hook: called before unload
19+
}
20+
21+
export class PluginSystem {
22+
private plugins = new Map<string, PluginDefinition>();
23+
private loaded = new Set<string>();
24+
25+
/**
26+
* Load a plugin into the system
27+
* @param plugin The plugin definition to load
28+
* @throws Error if dependencies are missing
29+
*/
30+
async loadPlugin(plugin: PluginDefinition): Promise<void> {
31+
// Check if already loaded
32+
if (this.loaded.has(plugin.name)) {
33+
console.warn(`Plugin "${plugin.name}" is already loaded. Skipping.`);
34+
return;
35+
}
36+
37+
// Check dependencies
38+
for (const dep of plugin.dependencies || []) {
39+
if (!this.loaded.has(dep)) {
40+
throw new Error(`Missing dependency: ${dep} required by ${plugin.name}`);
41+
}
42+
}
43+
44+
// Store plugin definition
45+
this.plugins.set(plugin.name, plugin);
46+
47+
// Execute lifecycle hook
48+
await plugin.onLoad?.();
49+
50+
// Mark as loaded
51+
this.loaded.add(plugin.name);
52+
}
53+
54+
/**
55+
* Unload a plugin from the system
56+
* @param name The name of the plugin to unload
57+
* @throws Error if other plugins depend on this plugin
58+
*/
59+
async unloadPlugin(name: string): Promise<void> {
60+
const plugin = this.plugins.get(name);
61+
if (!plugin) {
62+
throw new Error(`Plugin "${name}" is not loaded`);
63+
}
64+
65+
// Check if any loaded plugins depend on this one
66+
for (const [pluginName, pluginDef] of this.plugins.entries()) {
67+
if (this.loaded.has(pluginName) && pluginDef.dependencies?.includes(name)) {
68+
throw new Error(`Cannot unload plugin "${name}" - plugin "${pluginName}" depends on it`);
69+
}
70+
}
71+
72+
// Execute lifecycle hook
73+
await plugin.onUnload?.();
74+
75+
// Remove from loaded set
76+
this.loaded.delete(name);
77+
this.plugins.delete(name);
78+
}
79+
80+
/**
81+
* Check if a plugin is loaded
82+
* @param name The name of the plugin
83+
* @returns true if the plugin is loaded
84+
*/
85+
isLoaded(name: string): boolean {
86+
return this.loaded.has(name);
87+
}
88+
89+
/**
90+
* Get a loaded plugin definition
91+
* @param name The name of the plugin
92+
* @returns The plugin definition or undefined
93+
*/
94+
getPlugin(name: string): PluginDefinition | undefined {
95+
return this.plugins.get(name);
96+
}
97+
98+
/**
99+
* Get all loaded plugin names
100+
* @returns Array of loaded plugin names
101+
*/
102+
getLoadedPlugins(): string[] {
103+
return Array.from(this.loaded);
104+
}
105+
106+
/**
107+
* Get all plugin definitions
108+
* @returns Array of all plugin definitions
109+
*/
110+
getAllPlugins(): PluginDefinition[] {
111+
return Array.from(this.plugins.values());
112+
}
113+
}
Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,194 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { describe, it, expect, vi, beforeEach } from 'vitest';
10+
import { PluginSystem, type PluginDefinition } from '../PluginSystem';
11+
import { Registry } from '../Registry';
12+
13+
describe('PluginSystem', () => {
14+
let pluginSystem: PluginSystem;
15+
let registry: Registry;
16+
17+
beforeEach(() => {
18+
pluginSystem = new PluginSystem();
19+
registry = new Registry();
20+
});
21+
22+
it('should load a simple plugin', async () => {
23+
const plugin: PluginDefinition = {
24+
name: 'test-plugin',
25+
version: '1.0.0',
26+
register: (reg) => {
27+
reg.register('test', () => 'test');
28+
}
29+
};
30+
31+
await pluginSystem.loadPlugin(plugin);
32+
33+
expect(pluginSystem.isLoaded('test-plugin')).toBe(true);
34+
expect(pluginSystem.getLoadedPlugins()).toContain('test-plugin');
35+
});
36+
37+
it('should execute onLoad lifecycle hook', async () => {
38+
const onLoad = vi.fn();
39+
const plugin: PluginDefinition = {
40+
name: 'test-plugin',
41+
version: '1.0.0',
42+
register: () => {},
43+
onLoad
44+
};
45+
46+
await pluginSystem.loadPlugin(plugin);
47+
48+
expect(onLoad).toHaveBeenCalledTimes(1);
49+
});
50+
51+
it('should execute async onLoad lifecycle hook', async () => {
52+
const onLoad = vi.fn().mockResolvedValue(undefined);
53+
const plugin: PluginDefinition = {
54+
name: 'test-plugin',
55+
version: '1.0.0',
56+
register: () => {},
57+
onLoad
58+
};
59+
60+
await pluginSystem.loadPlugin(plugin);
61+
62+
expect(onLoad).toHaveBeenCalledTimes(1);
63+
});
64+
65+
it('should not load plugin twice', async () => {
66+
const onLoad = vi.fn();
67+
const plugin: PluginDefinition = {
68+
name: 'test-plugin',
69+
version: '1.0.0',
70+
register: () => {},
71+
onLoad
72+
};
73+
74+
await pluginSystem.loadPlugin(plugin);
75+
await pluginSystem.loadPlugin(plugin);
76+
77+
expect(onLoad).toHaveBeenCalledTimes(1);
78+
});
79+
80+
it('should check dependencies before loading', async () => {
81+
const plugin: PluginDefinition = {
82+
name: 'dependent-plugin',
83+
version: '1.0.0',
84+
dependencies: ['base-plugin'],
85+
register: () => {}
86+
};
87+
88+
await expect(pluginSystem.loadPlugin(plugin)).rejects.toThrow(
89+
'Missing dependency: base-plugin required by dependent-plugin'
90+
);
91+
});
92+
93+
it('should load plugins with dependencies in correct order', async () => {
94+
const basePlugin: PluginDefinition = {
95+
name: 'base-plugin',
96+
version: '1.0.0',
97+
register: () => {}
98+
};
99+
100+
const dependentPlugin: PluginDefinition = {
101+
name: 'dependent-plugin',
102+
version: '1.0.0',
103+
dependencies: ['base-plugin'],
104+
register: () => {}
105+
};
106+
107+
await pluginSystem.loadPlugin(basePlugin);
108+
await pluginSystem.loadPlugin(dependentPlugin);
109+
110+
expect(pluginSystem.isLoaded('base-plugin')).toBe(true);
111+
expect(pluginSystem.isLoaded('dependent-plugin')).toBe(true);
112+
});
113+
114+
it('should unload a plugin', async () => {
115+
const onUnload = vi.fn();
116+
const plugin: PluginDefinition = {
117+
name: 'test-plugin',
118+
version: '1.0.0',
119+
register: () => {},
120+
onUnload
121+
};
122+
123+
await pluginSystem.loadPlugin(plugin);
124+
expect(pluginSystem.isLoaded('test-plugin')).toBe(true);
125+
126+
await pluginSystem.unloadPlugin('test-plugin');
127+
128+
expect(pluginSystem.isLoaded('test-plugin')).toBe(false);
129+
expect(onUnload).toHaveBeenCalledTimes(1);
130+
});
131+
132+
it('should prevent unloading plugin with dependents', async () => {
133+
const basePlugin: PluginDefinition = {
134+
name: 'base-plugin',
135+
version: '1.0.0',
136+
register: () => {}
137+
};
138+
139+
const dependentPlugin: PluginDefinition = {
140+
name: 'dependent-plugin',
141+
version: '1.0.0',
142+
dependencies: ['base-plugin'],
143+
register: () => {}
144+
};
145+
146+
await pluginSystem.loadPlugin(basePlugin);
147+
await pluginSystem.loadPlugin(dependentPlugin);
148+
149+
await expect(pluginSystem.unloadPlugin('base-plugin')).rejects.toThrow(
150+
'Cannot unload plugin "base-plugin" - plugin "dependent-plugin" depends on it'
151+
);
152+
});
153+
154+
it('should throw error when unloading non-existent plugin', async () => {
155+
await expect(pluginSystem.unloadPlugin('non-existent')).rejects.toThrow(
156+
'Plugin "non-existent" is not loaded'
157+
);
158+
});
159+
160+
it('should get plugin definition', async () => {
161+
const plugin: PluginDefinition = {
162+
name: 'test-plugin',
163+
version: '1.0.0',
164+
register: () => {}
165+
};
166+
167+
await pluginSystem.loadPlugin(plugin);
168+
169+
const retrieved = pluginSystem.getPlugin('test-plugin');
170+
expect(retrieved).toBe(plugin);
171+
});
172+
173+
it('should get all plugins', async () => {
174+
const plugin1: PluginDefinition = {
175+
name: 'plugin-1',
176+
version: '1.0.0',
177+
register: () => {}
178+
};
179+
180+
const plugin2: PluginDefinition = {
181+
name: 'plugin-2',
182+
version: '1.0.0',
183+
register: () => {}
184+
};
185+
186+
await pluginSystem.loadPlugin(plugin1);
187+
await pluginSystem.loadPlugin(plugin2);
188+
189+
const allPlugins = pluginSystem.getAllPlugins();
190+
expect(allPlugins).toHaveLength(2);
191+
expect(allPlugins).toContain(plugin1);
192+
expect(allPlugins).toContain(plugin2);
193+
});
194+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import React, { lazy, Suspense } from 'react';
10+
11+
export interface LazyPluginOptions {
12+
/**
13+
* Fallback component to show while loading
14+
*/
15+
fallback?: React.ReactNode;
16+
}
17+
18+
/**
19+
* Create a lazy-loaded plugin component with Suspense wrapper
20+
*
21+
* @param importFn - Dynamic import function that returns a module with default export
22+
* @param options - Configuration options for the lazy plugin
23+
* @returns A component that lazy loads the plugin
24+
*
25+
* @example
26+
* ```tsx
27+
* // Basic usage
28+
* const ObjectGrid = createLazyPlugin(
29+
* () => import('@object-ui/plugin-grid')
30+
* );
31+
*
32+
* // With custom fallback
33+
* const ObjectGrid = createLazyPlugin(
34+
* () => import('@object-ui/plugin-grid'),
35+
* { fallback: <div>Loading grid...</div> }
36+
* );
37+
* ```
38+
*/
39+
export function createLazyPlugin<P = any>(
40+
importFn: () => Promise<{ default: React.ComponentType<P> }>,
41+
options?: LazyPluginOptions
42+
): React.ComponentType<P> {
43+
const LazyComponent = lazy(importFn);
44+
45+
return (props: P) => (
46+
<Suspense fallback={options?.fallback || null}>
47+
<LazyComponent {...(props as any)} />
48+
</Suspense>
49+
);
50+
}

0 commit comments

Comments
 (0)