Skip to content

Commit 9654ca4

Browse files
authored
Merge pull request #282 from objectstack-ai/copilot/enhance-plugin-registration
2 parents ecaa5d6 + 432cba1 commit 9654ca4

14 files changed

Lines changed: 1594 additions & 0 deletions

File tree

PLUGIN_SYSTEM.md

Lines changed: 512 additions & 0 deletions
Large diffs are not rendered by default.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
"lint": "pnpm -r lint",
2929
"cli": "node packages/cli/dist/cli.js",
3030
"objectui": "node packages/cli/dist/cli.js",
31+
"create-plugin": "node packages/create-plugin/dist/index.js",
3132
"storybook": "storybook dev -p 6006",
3233
"storybook:build": "storybook build",
3334
"storybook:test": "test-storybook --testTimeout=90000",

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: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
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+
* @param registry The component registry to use for registration
29+
* @throws Error if dependencies are missing
30+
*/
31+
async loadPlugin(plugin: PluginDefinition, registry: Registry): Promise<void> {
32+
// Check if already loaded
33+
if (this.loaded.has(plugin.name)) {
34+
console.warn(`Plugin "${plugin.name}" is already loaded. Skipping.`);
35+
return;
36+
}
37+
38+
// Check dependencies
39+
for (const dep of plugin.dependencies || []) {
40+
if (!this.loaded.has(dep)) {
41+
throw new Error(`Missing dependency: ${dep} required by ${plugin.name}`);
42+
}
43+
}
44+
45+
try {
46+
// Execute registration
47+
plugin.register(registry);
48+
49+
// Store plugin definition
50+
this.plugins.set(plugin.name, plugin);
51+
52+
// Execute lifecycle hook
53+
await plugin.onLoad?.();
54+
55+
// Mark as loaded
56+
this.loaded.add(plugin.name);
57+
} catch (error) {
58+
// Clean up on failure
59+
this.plugins.delete(plugin.name);
60+
throw error;
61+
}
62+
}
63+
64+
/**
65+
* Unload a plugin from the system
66+
* @param name The name of the plugin to unload
67+
* @throws Error if other plugins depend on this plugin
68+
*/
69+
async unloadPlugin(name: string): Promise<void> {
70+
const plugin = this.plugins.get(name);
71+
if (!plugin) {
72+
throw new Error(`Plugin "${name}" is not loaded`);
73+
}
74+
75+
// Check if any loaded plugins depend on this one
76+
for (const [pluginName, pluginDef] of this.plugins.entries()) {
77+
if (this.loaded.has(pluginName) && pluginDef.dependencies?.includes(name)) {
78+
throw new Error(`Cannot unload plugin "${name}" - plugin "${pluginName}" depends on it`);
79+
}
80+
}
81+
82+
// Execute lifecycle hook
83+
await plugin.onUnload?.();
84+
85+
// Remove from loaded set
86+
this.loaded.delete(name);
87+
this.plugins.delete(name);
88+
}
89+
90+
/**
91+
* Check if a plugin is loaded
92+
* @param name The name of the plugin
93+
* @returns true if the plugin is loaded
94+
*/
95+
isLoaded(name: string): boolean {
96+
return this.loaded.has(name);
97+
}
98+
99+
/**
100+
* Get a loaded plugin definition
101+
* @param name The name of the plugin
102+
* @returns The plugin definition or undefined
103+
*/
104+
getPlugin(name: string): PluginDefinition | undefined {
105+
return this.plugins.get(name);
106+
}
107+
108+
/**
109+
* Get all loaded plugin names
110+
* @returns Array of loaded plugin names
111+
*/
112+
getLoadedPlugins(): string[] {
113+
return Array.from(this.loaded);
114+
}
115+
116+
/**
117+
* Get all plugin definitions
118+
* @returns Array of all plugin definitions
119+
*/
120+
getAllPlugins(): PluginDefinition[] {
121+
return Array.from(this.plugins.values());
122+
}
123+
}
Lines changed: 224 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,224 @@
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, registry);
32+
33+
expect(pluginSystem.isLoaded('test-plugin')).toBe(true);
34+
expect(pluginSystem.getLoadedPlugins()).toContain('test-plugin');
35+
expect(registry.has('test')).toBe(true);
36+
});
37+
38+
it('should execute onLoad lifecycle hook', async () => {
39+
const onLoad = vi.fn();
40+
const plugin: PluginDefinition = {
41+
name: 'test-plugin',
42+
version: '1.0.0',
43+
register: () => {},
44+
onLoad
45+
};
46+
47+
await pluginSystem.loadPlugin(plugin, registry);
48+
49+
expect(onLoad).toHaveBeenCalledTimes(1);
50+
});
51+
52+
it('should execute async onLoad lifecycle hook', async () => {
53+
const onLoad = vi.fn().mockResolvedValue(undefined);
54+
const plugin: PluginDefinition = {
55+
name: 'test-plugin',
56+
version: '1.0.0',
57+
register: () => {},
58+
onLoad
59+
};
60+
61+
await pluginSystem.loadPlugin(plugin, registry);
62+
63+
expect(onLoad).toHaveBeenCalledTimes(1);
64+
});
65+
66+
it('should not load plugin twice', async () => {
67+
const onLoad = vi.fn();
68+
const plugin: PluginDefinition = {
69+
name: 'test-plugin',
70+
version: '1.0.0',
71+
register: () => {},
72+
onLoad
73+
};
74+
75+
await pluginSystem.loadPlugin(plugin, registry);
76+
await pluginSystem.loadPlugin(plugin, registry);
77+
78+
expect(onLoad).toHaveBeenCalledTimes(1);
79+
});
80+
81+
it('should check dependencies before loading', async () => {
82+
const plugin: PluginDefinition = {
83+
name: 'dependent-plugin',
84+
version: '1.0.0',
85+
dependencies: ['base-plugin'],
86+
register: () => {}
87+
};
88+
89+
await expect(pluginSystem.loadPlugin(plugin, registry)).rejects.toThrow(
90+
'Missing dependency: base-plugin required by dependent-plugin'
91+
);
92+
});
93+
94+
it('should load plugins with dependencies in correct order', async () => {
95+
const basePlugin: PluginDefinition = {
96+
name: 'base-plugin',
97+
version: '1.0.0',
98+
register: () => {}
99+
};
100+
101+
const dependentPlugin: PluginDefinition = {
102+
name: 'dependent-plugin',
103+
version: '1.0.0',
104+
dependencies: ['base-plugin'],
105+
register: () => {}
106+
};
107+
108+
await pluginSystem.loadPlugin(basePlugin, registry);
109+
await pluginSystem.loadPlugin(dependentPlugin, registry);
110+
111+
expect(pluginSystem.isLoaded('base-plugin')).toBe(true);
112+
expect(pluginSystem.isLoaded('dependent-plugin')).toBe(true);
113+
});
114+
115+
it('should unload a plugin', async () => {
116+
const onUnload = vi.fn();
117+
const plugin: PluginDefinition = {
118+
name: 'test-plugin',
119+
version: '1.0.0',
120+
register: () => {},
121+
onUnload
122+
};
123+
124+
await pluginSystem.loadPlugin(plugin, registry);
125+
expect(pluginSystem.isLoaded('test-plugin')).toBe(true);
126+
127+
await pluginSystem.unloadPlugin('test-plugin');
128+
129+
expect(pluginSystem.isLoaded('test-plugin')).toBe(false);
130+
expect(onUnload).toHaveBeenCalledTimes(1);
131+
});
132+
133+
it('should prevent unloading plugin with dependents', async () => {
134+
const basePlugin: PluginDefinition = {
135+
name: 'base-plugin',
136+
version: '1.0.0',
137+
register: () => {}
138+
};
139+
140+
const dependentPlugin: PluginDefinition = {
141+
name: 'dependent-plugin',
142+
version: '1.0.0',
143+
dependencies: ['base-plugin'],
144+
register: () => {}
145+
};
146+
147+
await pluginSystem.loadPlugin(basePlugin, registry);
148+
await pluginSystem.loadPlugin(dependentPlugin, registry);
149+
150+
await expect(pluginSystem.unloadPlugin('base-plugin')).rejects.toThrow(
151+
'Cannot unload plugin "base-plugin" - plugin "dependent-plugin" depends on it'
152+
);
153+
});
154+
155+
it('should throw error when unloading non-existent plugin', async () => {
156+
await expect(pluginSystem.unloadPlugin('non-existent')).rejects.toThrow(
157+
'Plugin "non-existent" is not loaded'
158+
);
159+
});
160+
161+
it('should get plugin definition', async () => {
162+
const plugin: PluginDefinition = {
163+
name: 'test-plugin',
164+
version: '1.0.0',
165+
register: () => {}
166+
};
167+
168+
await pluginSystem.loadPlugin(plugin, registry);
169+
170+
const retrieved = pluginSystem.getPlugin('test-plugin');
171+
expect(retrieved).toBe(plugin);
172+
});
173+
174+
it('should get all plugins', async () => {
175+
const plugin1: PluginDefinition = {
176+
name: 'plugin-1',
177+
version: '1.0.0',
178+
register: () => {}
179+
};
180+
181+
const plugin2: PluginDefinition = {
182+
name: 'plugin-2',
183+
version: '1.0.0',
184+
register: () => {}
185+
};
186+
187+
await pluginSystem.loadPlugin(plugin1, registry);
188+
await pluginSystem.loadPlugin(plugin2, registry);
189+
190+
const allPlugins = pluginSystem.getAllPlugins();
191+
expect(allPlugins).toHaveLength(2);
192+
expect(allPlugins).toContain(plugin1);
193+
expect(allPlugins).toContain(plugin2);
194+
});
195+
196+
it('should call register function with registry', async () => {
197+
const registerFn = vi.fn();
198+
const plugin: PluginDefinition = {
199+
name: 'test-plugin',
200+
version: '1.0.0',
201+
register: registerFn
202+
};
203+
204+
await pluginSystem.loadPlugin(plugin, registry);
205+
206+
expect(registerFn).toHaveBeenCalledWith(registry);
207+
expect(registerFn).toHaveBeenCalledTimes(1);
208+
});
209+
210+
it('should cleanup on registration failure', async () => {
211+
const plugin: PluginDefinition = {
212+
name: 'failing-plugin',
213+
version: '1.0.0',
214+
register: () => {
215+
throw new Error('Registration failed');
216+
}
217+
};
218+
219+
await expect(pluginSystem.loadPlugin(plugin, registry)).rejects.toThrow('Registration failed');
220+
221+
expect(pluginSystem.isLoaded('failing-plugin')).toBe(false);
222+
expect(pluginSystem.getPlugin('failing-plugin')).toBeUndefined();
223+
});
224+
});

0 commit comments

Comments
 (0)