@kanian77/choux 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +148 -0
- package/bun.lock +36 -0
- package/index.ts +4 -0
- package/package.json +39 -0
- package/plugins/example-plugin/module.ts +80 -0
- package/plugins/example-plugin/services.ts +22 -0
- package/plugins/example-plugin/tokens.ts +1 -0
- package/src/core/ChouxModule.ts +17 -0
- package/src/core/HookRegistry.spec.ts +75 -0
- package/src/core/HookRegistry.ts +78 -0
- package/src/core/Plugin.ts +26 -0
- package/src/core/PluginLoader.spec.ts +138 -0
- package/src/core/PluginLoader.ts +192 -0
- package/src/core/PluginManager.spec.ts +57 -0
- package/src/core/PluginManager.ts +47 -0
- package/src/core/index.ts +4 -0
- package/src/decorators/hookDecorator.ts +30 -0
- package/src/decorators/index.ts +1 -0
- package/src/lib/functions/index.ts +2 -0
- package/src/lib/functions/registerHookHandler.ts +11 -0
- package/src/lib/functions/registerHooksForInstance.ts +26 -0
- package/src/lib/test-related/plugins/plugin-a/module.ts +74 -0
- package/src/lib/test-related/plugins/plugin-b/module.ts +74 -0
- package/src/lib/types/HookMetadata.ts +4 -0
- package/src/lib/types/IHookRegistry.ts +10 -0
- package/src/lib/types/IPluginManager.ts +8 -0
- package/src/lib/types/LoadedPlugin.ts +9 -0
- package/src/lib/types/PluginMetadata.ts +9 -0
- package/src/lib/types/README.md +3 -0
- package/src/lib/types/index.ts +5 -0
- package/src/lib/types/tokens.ts +4 -0
- package/tsconfig.json +32 -0
package/README.md
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
# Choux Plugin System
|
|
2
|
+
|
|
3
|
+
Choux is a modular, type-safe plugin system for Node.js/TypeScript applications, built on top of the `@kanian77/simple-di` dependency injection framework. It enables dynamic plugin discovery, loading, lifecycle management, and custom hooks, making it easy to extend your application with isolated, reusable modules.
|
|
4
|
+
|
|
5
|
+
## Core Concepts
|
|
6
|
+
|
|
7
|
+
### Plugin
|
|
8
|
+
|
|
9
|
+
A plugin is a class extending the abstract `Plugin` base class (`src/core/Plugin.ts`). Each plugin must define metadata (name, version, optional description and dependencies) and can implement lifecycle methods:
|
|
10
|
+
|
|
11
|
+
- `onLoad`: Called when the plugin is loaded, before dependencies are resolved.
|
|
12
|
+
- `onInit`: Called after dependencies are resolved and injected.
|
|
13
|
+
- `onDestroy`: Called when the plugin is being unloaded.
|
|
14
|
+
- `registerCustomHooks`: Register custom hooks for other plugins to use.
|
|
15
|
+
|
|
16
|
+
Plugins are typically defined in their own folder (e.g. `plugins/example-plugin/`) with a `module.ts` file exporting the plugin class as default.
|
|
17
|
+
|
|
18
|
+
### Plugin Discovery & Loading
|
|
19
|
+
|
|
20
|
+
- **PluginLoader (`src/core/PluginLoader.ts`)**: Scans a plugins directory for valid plugins (folders containing `module.ts`), loads them, resolves dependencies, and manages their lifecycle. Plugins are loaded dynamically using ES module imports.
|
|
21
|
+
- **PluginManager (`src/core/PluginManager.ts`)**: High-level manager for initializing and shutting down all plugins, using the loader. It also exposes methods to get loaded plugins and their services.
|
|
22
|
+
|
|
23
|
+
### Dependency Injection
|
|
24
|
+
|
|
25
|
+
All plugins and core services use the DI system from `@kanian77/simple-di`. Tokens (see `src/core/tokens.ts`) are used to register and inject services, including plugin services and the hook registry.
|
|
26
|
+
|
|
27
|
+
### Hooks & Events
|
|
28
|
+
|
|
29
|
+
- **HookRegistry (`src/core/HookRegistry.ts`)**: Central registry for registering, triggering, and managing hooks/events. Plugins can register handlers for lifecycle or custom hooks, and trigger hooks to communicate with other plugins.
|
|
30
|
+
- **Lifecycle Hooks**: Standard hooks for plugin load/init/destroy and application start/shutdown (see `LIFECYCLE_HOOKS`).
|
|
31
|
+
- **Custom Hooks**: Plugins can define and trigger their own hooks for extensibility.
|
|
32
|
+
|
|
33
|
+
### Decorators & Utilities
|
|
34
|
+
|
|
35
|
+
- **Hook Decorator (`src/decorators/hookDecorator.ts`)**: Provides a decorator and utility for registering hook handlers in plugin classes.
|
|
36
|
+
- **Function Utilities (`src/lib/functions/`)**: Helper functions for hook registration and plugin management.
|
|
37
|
+
- **Types (`src/lib/types/`)**: Shared interfaces and type definitions for plugins, hook registry, plugin loader/manager, and metadata.
|
|
38
|
+
|
|
39
|
+
## Example Plugin Structure
|
|
40
|
+
|
|
41
|
+
See `plugins/example-plugin/` for a reference implementation:
|
|
42
|
+
|
|
43
|
+
- `module.ts`: Exports the plugin class, registers services, and hooks.
|
|
44
|
+
- `services.ts`: Defines plugin-specific services, which can be injected elsewhere.
|
|
45
|
+
- `tokens.ts`: Declares DI tokens for plugin services.
|
|
46
|
+
|
|
47
|
+
## Usage
|
|
48
|
+
|
|
49
|
+
### Bootstrapping
|
|
50
|
+
|
|
51
|
+
See `src/main.ts` for application startup:
|
|
52
|
+
|
|
53
|
+
- Create a DI module importing the core module.
|
|
54
|
+
- Bootstrap the DI system.
|
|
55
|
+
- Get the plugin manager and initialize plugins from the plugins directory.
|
|
56
|
+
- Access plugin services via DI tokens.
|
|
57
|
+
|
|
58
|
+
### Accessing Plugin Services
|
|
59
|
+
|
|
60
|
+
You can inject services provided by plugins using their tokens:
|
|
61
|
+
|
|
62
|
+
```typescript
|
|
63
|
+
import { inject } from '@kanian77/simple-di';
|
|
64
|
+
import { EXAMPLE_SERVICE_TOKEN } from '../plugins/example-plugin/tokens';
|
|
65
|
+
import { IExampleService } from '../plugins/example-plugin/services';
|
|
66
|
+
|
|
67
|
+
const exampleService = inject<IExampleService>(EXAMPLE_SERVICE_TOKEN);
|
|
68
|
+
exampleService.doSomething();
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
## Extending Choux
|
|
72
|
+
|
|
73
|
+
- Add new plugins by creating a folder with a `module.ts` exporting a class extending `Plugin`.
|
|
74
|
+
- Register services and hooks in your plugin.
|
|
75
|
+
- Use the hook registry to communicate between plugins.
|
|
76
|
+
- Use DI tokens for type-safe service access.
|
|
77
|
+
|
|
78
|
+
## Directory Overview
|
|
79
|
+
|
|
80
|
+
- `src/core/`: Core plugin system (Plugin, PluginLoader, PluginManager, HookRegistry, tokens)
|
|
81
|
+
- `src/decorators/`: Hook decorator and registration utilities
|
|
82
|
+
- `src/lib/functions/`: Helper functions for hooks and plugins
|
|
83
|
+
- `src/lib/types/`: Shared type definitions
|
|
84
|
+
- `plugins/`: Example and user plugins
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
89
|
+
## What's Already Great ✅
|
|
90
|
+
|
|
91
|
+
- **Core Architecture**: Plugin extends Module, proper DI integration
|
|
92
|
+
- **Lifecycle Management**: Load, init, destroy with proper ordering
|
|
93
|
+
- **Hook System**: Both decorators and manual registration
|
|
94
|
+
- **Dependency Resolution**: Plugin-level and module-level dependencies
|
|
95
|
+
- **Type Safety**: Full TypeScript support with tokens
|
|
96
|
+
- **Documentation**: Clear structure and examples
|
|
97
|
+
|
|
98
|
+
## Areas for Enhancement 🔧
|
|
99
|
+
|
|
100
|
+
### 1. **Plugin Configuration System**
|
|
101
|
+
```typescript
|
|
102
|
+
// src/core/pluginConfig.ts
|
|
103
|
+
export interface PluginConfig {
|
|
104
|
+
[pluginName: string]: {
|
|
105
|
+
enabled?: boolean;
|
|
106
|
+
config?: Record<string, any>;
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// In PluginLoader
|
|
111
|
+
async loadPlugins(pluginsDir: string, config?: PluginConfig): Promise<LoadedPlugin[]>
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
### 2. **Plugin Validation & Health Checks**
|
|
115
|
+
```typescript
|
|
116
|
+
// Add to Plugin base class
|
|
117
|
+
async healthCheck?(): Promise<{ healthy: boolean; message?: string }>;
|
|
118
|
+
|
|
119
|
+
// Plugin metadata validation
|
|
120
|
+
export function validatePluginMetadata(metadata: PluginMetadata): string[] {
|
|
121
|
+
const errors: string[] = [];
|
|
122
|
+
if (!metadata.name) errors.push('Plugin name is required');
|
|
123
|
+
// ... more validations
|
|
124
|
+
return errors;
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### 3. **Error Handling & Recovery**
|
|
129
|
+
```typescript
|
|
130
|
+
// Enhanced error handling in PluginLoader
|
|
131
|
+
interface PluginLoadError {
|
|
132
|
+
pluginPath: string;
|
|
133
|
+
error: Error;
|
|
134
|
+
phase: 'discovery' | 'load' | 'init';
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Add retry mechanisms for failed plugins
|
|
138
|
+
async retryFailedPlugin(pluginName: string): Promise<void>
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
### 4. **Plugin Hot Reloading** (Development)
|
|
142
|
+
```typescript
|
|
143
|
+
// src/core/pluginWatcher.ts
|
|
144
|
+
export class PluginWatcher {
|
|
145
|
+
async watchForChanges(pluginsDir: string): Promise<void>;
|
|
146
|
+
async reloadPlugin(pluginName: string): Promise<void>;
|
|
147
|
+
}
|
|
148
|
+
```
|
package/bun.lock
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"lockfileVersion": 1,
|
|
3
|
+
"workspaces": {
|
|
4
|
+
"": {
|
|
5
|
+
"name": "@kanian77/choux",
|
|
6
|
+
"dependencies": {
|
|
7
|
+
"@kanian77/simple-di": "^0.1.2",
|
|
8
|
+
},
|
|
9
|
+
"devDependencies": {
|
|
10
|
+
"@types/bun": "latest",
|
|
11
|
+
},
|
|
12
|
+
"peerDependencies": {
|
|
13
|
+
"typescript": "^5",
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
},
|
|
17
|
+
"packages": {
|
|
18
|
+
"@kanian77/simple-di": ["@kanian77/simple-di@0.1.2", "", { "dependencies": { "@types/bun": "^1.2.4", "reflect-metadata": "^0.2.2" } }, "sha512-sTf0ul3ckEncXugV5eB7sdwLuP8F2ScryXqKtOheQ7WwKVK4xs5/oAj2xihDHXKkjgfVvcnnqWhXGCky67PVsg=="],
|
|
19
|
+
|
|
20
|
+
"@types/bun": ["@types/bun@1.2.19", "", { "dependencies": { "bun-types": "1.2.19" } }, "sha512-d9ZCmrH3CJ2uYKXQIUuZ/pUnTqIvLDS0SK7pFmbx8ma+ziH/FRMoAq5bYpRG7y+w1gl+HgyNZbtqgMq4W4e2Lg=="],
|
|
21
|
+
|
|
22
|
+
"@types/node": ["@types/node@24.1.0", "", { "dependencies": { "undici-types": "~7.8.0" } }, "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w=="],
|
|
23
|
+
|
|
24
|
+
"@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="],
|
|
25
|
+
|
|
26
|
+
"bun-types": ["bun-types@1.2.19", "", { "dependencies": { "@types/node": "*" }, "peerDependencies": { "@types/react": "^19" } }, "sha512-uAOTaZSPuYsWIXRpj7o56Let0g/wjihKCkeRqUBhlLVM/Bt+Fj9xTo+LhC1OV1XDaGkz4hNC80et5xgy+9KTHQ=="],
|
|
27
|
+
|
|
28
|
+
"csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="],
|
|
29
|
+
|
|
30
|
+
"reflect-metadata": ["reflect-metadata@0.2.2", "", {}, "sha512-urBwgfrvVP/eAyXx4hluJivBKzuEbSQs9rKWCrCkbSxNv8mxPcUZKeuoF3Uy4mJl3Lwprp6yy5/39VWigZ4K6Q=="],
|
|
31
|
+
|
|
32
|
+
"typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="],
|
|
33
|
+
|
|
34
|
+
"undici-types": ["undici-types@7.8.0", "", {}, "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw=="],
|
|
35
|
+
}
|
|
36
|
+
}
|
package/index.ts
ADDED
package/package.json
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kanian77/choux",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "A simple plugin framework for TypeScript.",
|
|
5
|
+
"author": {
|
|
6
|
+
"name": "Patrick Assoa Adou",
|
|
7
|
+
"email": "kanian77@gmail.com"
|
|
8
|
+
},
|
|
9
|
+
"repository": "git@github.com:kanian/choux.git",
|
|
10
|
+
"type": "module",
|
|
11
|
+
"main": "./dist/index.js",
|
|
12
|
+
"types": "./dist/index.d.ts",
|
|
13
|
+
"exports": {
|
|
14
|
+
".": {
|
|
15
|
+
"import": "./dist/index.js",
|
|
16
|
+
"require": "./dist/index.js",
|
|
17
|
+
"types": "./dist/index.d.ts"
|
|
18
|
+
},
|
|
19
|
+
"./dist/*": {
|
|
20
|
+
"import": "./dist/*.js",
|
|
21
|
+
"require": "./dist/*.js"
|
|
22
|
+
}
|
|
23
|
+
},
|
|
24
|
+
"scripts": {
|
|
25
|
+
"build": "tsc --build --verbose",
|
|
26
|
+
"dev": "tsc --build --verbose --watch",
|
|
27
|
+
"clean": "rm -rf dist && rm -rf tsconfig.tsbuildinfo",
|
|
28
|
+
"prepublishOnly": "bun run clean && bun run build"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/bun": "latest"
|
|
32
|
+
},
|
|
33
|
+
"peerDependencies": {
|
|
34
|
+
"typescript": "^5"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@kanian77/simple-di": "^0.1.2"
|
|
38
|
+
}
|
|
39
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import { Module } from '@kanian77/simple-di';
|
|
2
|
+
import { ExampleService } from './services';
|
|
3
|
+
import { EXAMPLE_SERVICE_TOKEN } from './tokens';
|
|
4
|
+
import { Hook } from '../../src/decorators/hookDecorator';
|
|
5
|
+
import { inject } from '@kanian77/simple-di';
|
|
6
|
+
import { Plugin } from '../../src/core/Plugin';
|
|
7
|
+
import type { IHookRegistry } from '../../src/lib/types';
|
|
8
|
+
import { HOOK_REGISTRY_TOKEN } from '../../src/lib/types/tokens';
|
|
9
|
+
|
|
10
|
+
export default class ExamplePlugin extends Plugin {
|
|
11
|
+
readonly metadata = {
|
|
12
|
+
name: 'example-plugin',
|
|
13
|
+
version: '1.0.0',
|
|
14
|
+
description: 'An example plugin',
|
|
15
|
+
dependencies: [], // other plugin names this depends on
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
constructor() {
|
|
19
|
+
// Call parent Module constructor with plugin configuration
|
|
20
|
+
super({
|
|
21
|
+
providers: [
|
|
22
|
+
{
|
|
23
|
+
provide: EXAMPLE_SERVICE_TOKEN,
|
|
24
|
+
useClass: ExampleService,
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
// Can also import other modules if needed
|
|
28
|
+
// imports: [SomeOtherModule],
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
override async onLoad(): Promise<void> {
|
|
33
|
+
console.log('ExamplePlugin loading...');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
override async onInit(): Promise<void> {
|
|
37
|
+
console.log('ExamplePlugin initialized!');
|
|
38
|
+
|
|
39
|
+
// Additional manual hook registration if needed
|
|
40
|
+
this.registerAdditionalHooks();
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
override async onDestroy(): Promise<void> {
|
|
44
|
+
console.log('ExamplePlugin destroyed!');
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
protected override registerCustomHooks(): void {
|
|
48
|
+
// Define custom hooks that this plugin provides
|
|
49
|
+
// Other plugins can listen to these hooks
|
|
50
|
+
console.log('Registering custom hooks for ExamplePlugin');
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// These methods will be automatically registered as hook handlers
|
|
54
|
+
@Hook('custom:example-event')
|
|
55
|
+
private async handleExampleEvent(data: any): Promise<void> {
|
|
56
|
+
console.log('Handling example event via decorator:', data);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Hook('core:application-start')
|
|
60
|
+
private async onApplicationStart(): Promise<void> {
|
|
61
|
+
console.log('ExamplePlugin responding to application start via decorator');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
@Hook('core:before-plugin-load')
|
|
65
|
+
private async onBeforePluginLoad(pluginPath: string): Promise<void> {
|
|
66
|
+
console.log('Another plugin is about to load:', pluginPath);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Manual hook registration for dynamic scenarios
|
|
70
|
+
private registerAdditionalHooks(): void {
|
|
71
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
72
|
+
|
|
73
|
+
// Register additional hooks manually if needed
|
|
74
|
+
hookRegistry.register('dynamic:custom-hook', this.handleDynamicHook.bind(this));
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async handleDynamicHook(data: any): Promise<void> {
|
|
78
|
+
console.log('Handling dynamic hook:', data);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { Service, Inject, inject } from '@kanian77/simple-di';
|
|
2
|
+
import { EXAMPLE_SERVICE_TOKEN } from './tokens';
|
|
3
|
+
import { HOOK_REGISTRY_TOKEN } from '../../src/lib/types/tokens';
|
|
4
|
+
import type { IHookRegistry } from '../../src/lib/types';
|
|
5
|
+
|
|
6
|
+
export interface IExampleService {
|
|
7
|
+
doSomething(): void;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
@Service({ token: EXAMPLE_SERVICE_TOKEN })
|
|
11
|
+
export class ExampleService implements IExampleService {
|
|
12
|
+
private hookRegistry: IHookRegistry;
|
|
13
|
+
constructor() {
|
|
14
|
+
this.hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
doSomething(): void {
|
|
18
|
+
console.log('ExampleService doing something...');
|
|
19
|
+
// Can trigger custom hooks
|
|
20
|
+
this.hookRegistry.trigger('example:something-done', { data: 'example' });
|
|
21
|
+
}
|
|
22
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export const EXAMPLE_SERVICE_TOKEN = Symbol('ExampleService');
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Module } from '@kanian77/simple-di';
|
|
2
|
+
import { HookRegistry } from './HookRegistry';
|
|
3
|
+
import { PluginManager } from './PluginManager';
|
|
4
|
+
import { HOOK_REGISTRY_TOKEN, PLUGIN_MANAGER_TOKEN } from '../lib/types/tokens';
|
|
5
|
+
|
|
6
|
+
export const ChouxModule = new Module({
|
|
7
|
+
providers: [
|
|
8
|
+
{
|
|
9
|
+
provide: HOOK_REGISTRY_TOKEN,
|
|
10
|
+
useClass: HookRegistry,
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
provide: PLUGIN_MANAGER_TOKEN,
|
|
14
|
+
useClass: PluginManager,
|
|
15
|
+
},
|
|
16
|
+
],
|
|
17
|
+
});
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach } from 'bun:test';
|
|
2
|
+
import { HookRegistry } from './HookRegistry';
|
|
3
|
+
import type { HookFn } from '../lib/types';
|
|
4
|
+
|
|
5
|
+
describe('HookRegistry', () => {
|
|
6
|
+
let registry: HookRegistry;
|
|
7
|
+
|
|
8
|
+
beforeEach(() => {
|
|
9
|
+
registry = new HookRegistry();
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it('registers and triggers a hook', async () => {
|
|
13
|
+
let called = false;
|
|
14
|
+
const fn: HookFn = () => {
|
|
15
|
+
called = true;
|
|
16
|
+
};
|
|
17
|
+
registry.register('test:hook', fn);
|
|
18
|
+
await registry.trigger('test:hook');
|
|
19
|
+
expect(called).toBe(true);
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it('unregisters a specific hook handler', async () => {
|
|
23
|
+
let called = false;
|
|
24
|
+
const fn: HookFn = () => {
|
|
25
|
+
called = true;
|
|
26
|
+
};
|
|
27
|
+
registry.register('test:hook', fn);
|
|
28
|
+
registry.unregister('test:hook', fn);
|
|
29
|
+
await registry.trigger('test:hook');
|
|
30
|
+
expect(called).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('unregisters all handlers for a hook', async () => {
|
|
34
|
+
let called = false;
|
|
35
|
+
const fn: HookFn = () => {
|
|
36
|
+
called = true;
|
|
37
|
+
};
|
|
38
|
+
registry.register('test:hook', fn);
|
|
39
|
+
registry.unregister('test:hook');
|
|
40
|
+
await registry.trigger('test:hook');
|
|
41
|
+
expect(called).toBe(false);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('getHooks returns all registered handlers', () => {
|
|
45
|
+
const fn1: HookFn = () => {};
|
|
46
|
+
const fn2: HookFn = () => {};
|
|
47
|
+
registry.register('test:hook', fn1);
|
|
48
|
+
registry.register('test:hook', fn2);
|
|
49
|
+
const hooks = registry.getHooks('test:hook');
|
|
50
|
+
expect(hooks.length).toBe(2);
|
|
51
|
+
expect(hooks).toContain(fn1);
|
|
52
|
+
expect(hooks).toContain(fn2);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('clear removes all hooks', () => {
|
|
56
|
+
const fn: HookFn = () => {};
|
|
57
|
+
registry.register('test:hook', fn);
|
|
58
|
+
registry.clear();
|
|
59
|
+
expect(registry.getHooks('test:hook').length).toBe(0);
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('trigger calls all registered async handlers', async () => {
|
|
63
|
+
let result = '';
|
|
64
|
+
const fn1: HookFn = async () => {
|
|
65
|
+
result += 'a';
|
|
66
|
+
};
|
|
67
|
+
const fn2: HookFn = async () => {
|
|
68
|
+
result += 'b';
|
|
69
|
+
};
|
|
70
|
+
registry.register('test:hook', fn1);
|
|
71
|
+
registry.register('test:hook', fn2);
|
|
72
|
+
await registry.trigger('test:hook');
|
|
73
|
+
expect(result).toBe('ab');
|
|
74
|
+
});
|
|
75
|
+
});
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { Service } from '@kanian77/simple-di';
|
|
2
|
+
import { HOOK_REGISTRY_TOKEN } from '../lib/types/tokens';
|
|
3
|
+
import type { HookFn, HookMap, IHookRegistry } from '../lib/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Registry for application hooks.
|
|
7
|
+
*/
|
|
8
|
+
@Service({ token: HOOK_REGISTRY_TOKEN })
|
|
9
|
+
export class HookRegistry implements IHookRegistry {
|
|
10
|
+
private hooks: HookMap = new Map();
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Register a hook handler.
|
|
14
|
+
* @param name The name of the hook to register the function for.
|
|
15
|
+
* @param fn The hook handler function.
|
|
16
|
+
*/
|
|
17
|
+
register(name: string, fn: HookFn): void {
|
|
18
|
+
const list = this.hooks.get(name) || [];
|
|
19
|
+
this.hooks.set(name, [...list, fn]);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Trigger a hook, calling all registered handlers.
|
|
24
|
+
* @param name The name of the hook to trigger.
|
|
25
|
+
* @param args Arguments to pass to the hook handlers.
|
|
26
|
+
*/
|
|
27
|
+
async trigger(name: string, ...args: any[]): Promise<void> {
|
|
28
|
+
const fns = this.hooks.get(name) || [];
|
|
29
|
+
for (const fn of fns) {
|
|
30
|
+
await fn(...args);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Unregister a hook handler.
|
|
36
|
+
* If no function is provided, all handlers for the hook will be removed.
|
|
37
|
+
* @param name The name of the hook to unregister from.
|
|
38
|
+
* @param fn Optional specific handler to remove.
|
|
39
|
+
*/
|
|
40
|
+
unregister(name: string, fn?: HookFn): void {
|
|
41
|
+
if (!fn) {
|
|
42
|
+
this.hooks.delete(name);
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const list = this.hooks.get(name) || [];
|
|
47
|
+
const filtered = list.filter((registeredFn) => registeredFn !== fn);
|
|
48
|
+
this.hooks.set(name, filtered);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Get all registered handlers for a specific hook.
|
|
53
|
+
* @param name The name of the hook to retrieve handlers for.
|
|
54
|
+
* @returns An array of hook handler functions.
|
|
55
|
+
*/
|
|
56
|
+
getHooks(name: string): HookFn[] {
|
|
57
|
+
return this.hooks.get(name) || [];
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Clear all registered hooks.
|
|
62
|
+
*/
|
|
63
|
+
clear(): void {
|
|
64
|
+
this.hooks.clear();
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Standard lifecycle hooks
|
|
69
|
+
export const LIFECYCLE_HOOKS = {
|
|
70
|
+
BEFORE_PLUGIN_LOAD: 'core:before-plugin-load',
|
|
71
|
+
AFTER_PLUGIN_LOAD: 'core:after-plugin-load',
|
|
72
|
+
BEFORE_PLUGIN_INIT: 'core:before-plugin-init',
|
|
73
|
+
AFTER_PLUGIN_INIT: 'core:after-plugin-init',
|
|
74
|
+
BEFORE_PLUGIN_DESTROY: 'core:before-plugin-destroy',
|
|
75
|
+
AFTER_PLUGIN_DESTROY: 'core:after-plugin-destroy',
|
|
76
|
+
APPLICATION_START: 'core:application-start',
|
|
77
|
+
APPLICATION_SHUTDOWN: 'core:application-shutdown',
|
|
78
|
+
} as const;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Module } from '@kanian77/simple-di';
|
|
2
|
+
import type { PluginMetadata } from '../lib/types';
|
|
3
|
+
|
|
4
|
+
export abstract class Plugin extends Module {
|
|
5
|
+
abstract readonly metadata: PluginMetadata;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Called when plugin is loaded but before dependencies are resolved
|
|
9
|
+
*/
|
|
10
|
+
async onLoad?(): Promise<void>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Called after all dependencies are resolved and injected
|
|
14
|
+
*/
|
|
15
|
+
async onInit?(): Promise<void>;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Called when plugin is being unloaded
|
|
19
|
+
*/
|
|
20
|
+
async onDestroy?(): Promise<void>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Register custom hooks specific to this plugin
|
|
24
|
+
*/
|
|
25
|
+
protected registerCustomHooks?(): void;
|
|
26
|
+
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { PluginLoader } from './PluginLoader';
|
|
3
|
+
import { HOOK_REGISTRY_TOKEN } from '../lib/types/tokens';
|
|
4
|
+
import { LIFECYCLE_HOOKS } from './HookRegistry';
|
|
5
|
+
import type { IHookRegistry, LoadedPlugin } from '../lib/types';
|
|
6
|
+
import { Plugin } from './Plugin';
|
|
7
|
+
import { tmpdir } from 'os';
|
|
8
|
+
import { mkdtempSync } from 'fs';
|
|
9
|
+
// Minimal IHookRegistry mock
|
|
10
|
+
const mockHookRegistry: IHookRegistry = {
|
|
11
|
+
trigger: async () => {},
|
|
12
|
+
register: () => {},
|
|
13
|
+
unregister: () => {},
|
|
14
|
+
getHooks: () => [],
|
|
15
|
+
clear: () => {},
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
// // Patch inject to return our mock
|
|
19
|
+
// import * as simpleDi from "@kanian77/simple-di";
|
|
20
|
+
// (simpleDi as any).inject = () => mockHookRegistry;
|
|
21
|
+
|
|
22
|
+
// Patch fs.promises for plugin discovery
|
|
23
|
+
import { mkdir, writeFile, rm } from 'fs/promises';
|
|
24
|
+
import { join, resolve } from 'path';
|
|
25
|
+
|
|
26
|
+
// Helper for plugin mocks
|
|
27
|
+
class PluginMock extends Plugin {
|
|
28
|
+
override metadata = { name: 'pluginA', version: '1.0.0' };
|
|
29
|
+
override imports = [];
|
|
30
|
+
override providers = [];
|
|
31
|
+
override async onLoad() {}
|
|
32
|
+
override async onInit() {}
|
|
33
|
+
override async onDestroy() {}
|
|
34
|
+
override registerCustomHooks() {}
|
|
35
|
+
}
|
|
36
|
+
class PluginMockA extends Plugin {
|
|
37
|
+
override metadata = { name: 'A', version: '1.0.0', dependencies: ['B'] };
|
|
38
|
+
override imports = [];
|
|
39
|
+
override providers = [];
|
|
40
|
+
}
|
|
41
|
+
class PluginMockB extends Plugin {
|
|
42
|
+
override metadata = { name: 'B', version: '1.0.0', dependencies: ['A'] };
|
|
43
|
+
override imports = [];
|
|
44
|
+
override providers = [];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
describe('PluginLoader', () => {
|
|
48
|
+
let loader: PluginLoader;
|
|
49
|
+
let tempRoot: string;
|
|
50
|
+
|
|
51
|
+
beforeEach(async () => {
|
|
52
|
+
loader = new PluginLoader();
|
|
53
|
+
tempRoot = mkdtempSync(join(tmpdir(), 'plugin-test-'));
|
|
54
|
+
|
|
55
|
+
// Create pluginA with module.ts
|
|
56
|
+
const pluginADir = join(tempRoot, 'pluginA');
|
|
57
|
+
await mkdir(pluginADir);
|
|
58
|
+
await writeFile(join(pluginADir, 'module.ts'), '// pluginA module');
|
|
59
|
+
|
|
60
|
+
// Create pluginB without module.ts
|
|
61
|
+
const pluginBDir = join(tempRoot, 'pluginB');
|
|
62
|
+
await mkdir(pluginBDir);
|
|
63
|
+
|
|
64
|
+
// Create a non-directory file
|
|
65
|
+
await writeFile(join(tempRoot, 'file.txt'), 'not a directory');
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
afterEach(async () => {
|
|
69
|
+
await rm(tempRoot, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it('findPlugins returns only directories with module.ts', async () => {
|
|
73
|
+
const plugins = await loader.findPlugins('./src/lib/test-related/plugins');
|
|
74
|
+
|
|
75
|
+
expect(plugins).toEqual([
|
|
76
|
+
'src/lib/test-related/plugins/plugin-a',
|
|
77
|
+
'src/lib/test-related/plugins/plugin-b',
|
|
78
|
+
]);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('loadPlugin throws if no default export', async () => {
|
|
82
|
+
const origJoin = require('path').join;
|
|
83
|
+
require('path').join = (...args: string[]) => args.join('/');
|
|
84
|
+
const origImport = (global as any).import;
|
|
85
|
+
(global as any).import = async (_: string) => ({});
|
|
86
|
+
await expect(
|
|
87
|
+
loader.loadPlugin('./src/lib/test-related/plugins/plugin-a')
|
|
88
|
+
).rejects.toThrow();
|
|
89
|
+
(global as any).import = origImport;
|
|
90
|
+
require('path').join = origJoin;
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('loadPlugin loads valid plugin and triggers hooks', async () => {
|
|
94
|
+
const modulePath = resolve('src/lib/test-related/plugins/plugin-a');
|
|
95
|
+
const loaded = await loader.loadPlugin(modulePath);
|
|
96
|
+
expect(loaded.plugin).toBeInstanceOf(
|
|
97
|
+
await import(join(modulePath, 'module.ts')).then((m) => m.default)
|
|
98
|
+
);
|
|
99
|
+
expect(loaded.metadata.name).toBe('pluginA');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('unloadPlugin triggers destroy hooks and removes plugin', async () => {
|
|
103
|
+
const loaded: LoadedPlugin = {
|
|
104
|
+
plugin: new PluginMock({}),
|
|
105
|
+
metadata: { name: 'pluginA', version: '1.0.0' },
|
|
106
|
+
pluginClass: PluginMock,
|
|
107
|
+
};
|
|
108
|
+
loader['loadedPlugins'].set('pluginA', loaded);
|
|
109
|
+
await loader.unloadPlugin('pluginA');
|
|
110
|
+
expect(loader.getPlugin('pluginA')).toBeUndefined();
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('getAllPlugins returns all loaded plugins', () => {
|
|
114
|
+
const loaded: LoadedPlugin = {
|
|
115
|
+
plugin: new PluginMock({}),
|
|
116
|
+
metadata: { name: 'pluginA', version: '1.0.0' },
|
|
117
|
+
pluginClass: PluginMock,
|
|
118
|
+
};
|
|
119
|
+
loader['loadedPlugins'].set('pluginA', loaded);
|
|
120
|
+
expect(loader.getAllPlugins()).toEqual([loaded]);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it('resolveDependencyOrder throws on circular dependency', () => {
|
|
124
|
+
const pluginA: LoadedPlugin = {
|
|
125
|
+
plugin: new PluginMockA({}),
|
|
126
|
+
metadata: { name: 'A', version: '1.0.0', dependencies: ['B'] },
|
|
127
|
+
pluginClass: PluginMockA,
|
|
128
|
+
};
|
|
129
|
+
const pluginB: LoadedPlugin = {
|
|
130
|
+
plugin: new PluginMockB({}),
|
|
131
|
+
metadata: { name: 'B', version: '1.0.0', dependencies: ['A'] },
|
|
132
|
+
pluginClass: PluginMockB,
|
|
133
|
+
};
|
|
134
|
+
expect(() =>
|
|
135
|
+
loader['resolveDependencyOrder']([pluginA, pluginB])
|
|
136
|
+
).toThrow();
|
|
137
|
+
});
|
|
138
|
+
});
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
import { Module, inject } from '@kanian77/simple-di';
|
|
2
|
+
import { Plugin } from './Plugin';
|
|
3
|
+
import type { PluginMetadata, LoadedPlugin, IHookRegistry } from '../lib/types';
|
|
4
|
+
import { LIFECYCLE_HOOKS } from './HookRegistry';
|
|
5
|
+
import { HOOK_REGISTRY_TOKEN } from '../lib/types/tokens';
|
|
6
|
+
import * as fs from 'fs/promises';
|
|
7
|
+
import * as path from 'path';
|
|
8
|
+
|
|
9
|
+
// LoadedPlugin now imported from types
|
|
10
|
+
|
|
11
|
+
export class PluginLoader {
|
|
12
|
+
private loadedPlugins: Map<string, LoadedPlugin> = new Map();
|
|
13
|
+
|
|
14
|
+
async findPlugins(pluginsDir: string): Promise<string[]> {
|
|
15
|
+
try {
|
|
16
|
+
const entries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
|
17
|
+
const pluginDirs: string[] = [];
|
|
18
|
+
|
|
19
|
+
for (const entry of entries) {
|
|
20
|
+
if (entry.isDirectory()) {
|
|
21
|
+
const pluginPath = path.join(pluginsDir, entry.name);
|
|
22
|
+
const modulePath = path.join(pluginPath, 'module.ts');
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
await fs.access(modulePath);
|
|
26
|
+
pluginDirs.push(pluginPath);
|
|
27
|
+
} catch {
|
|
28
|
+
// Skip directories without module.ts
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return pluginDirs;
|
|
34
|
+
} catch (error) {
|
|
35
|
+
console.warn(`Could not read plugins directory: ${pluginsDir}`, error);
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async loadPlugin(pluginPath: string): Promise<LoadedPlugin> {
|
|
41
|
+
const modulePath = path.join(pluginPath, 'module.ts');
|
|
42
|
+
console.log(`Loading plugin from ${modulePath}`);
|
|
43
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
44
|
+
await hookRegistry.trigger(LIFECYCLE_HOOKS.BEFORE_PLUGIN_LOAD, pluginPath);
|
|
45
|
+
|
|
46
|
+
// Dynamic import of the plugin module
|
|
47
|
+
const module = await import(modulePath);
|
|
48
|
+
const PluginClass = module.default;
|
|
49
|
+
|
|
50
|
+
if (!PluginClass || !PluginClass.prototype) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`Invalid plugin at ${pluginPath}: must export a Plugin class as default`
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Create plugin instance (which is also a Module)
|
|
57
|
+
const plugin = new PluginClass();
|
|
58
|
+
|
|
59
|
+
if (!plugin.metadata) {
|
|
60
|
+
throw new Error(`Plugin at ${pluginPath} must have metadata property`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Call plugin's onLoad lifecycle method
|
|
64
|
+
if (plugin.onLoad) {
|
|
65
|
+
await plugin.onLoad();
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Register custom hooks if defined
|
|
69
|
+
if (plugin.registerCustomHooks) {
|
|
70
|
+
plugin.registerCustomHooks();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const loadedPlugin: LoadedPlugin = {
|
|
74
|
+
plugin, // plugin is already a Module
|
|
75
|
+
metadata: plugin.metadata,
|
|
76
|
+
pluginClass: PluginClass,
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
this.loadedPlugins.set(plugin.metadata.name, loadedPlugin);
|
|
80
|
+
|
|
81
|
+
await hookRegistry.trigger(LIFECYCLE_HOOKS.AFTER_PLUGIN_LOAD, loadedPlugin);
|
|
82
|
+
|
|
83
|
+
return loadedPlugin;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
async loadPlugins(pluginsDir: string): Promise<LoadedPlugin[]> {
|
|
87
|
+
const pluginDirs = await this.findPlugins(pluginsDir);
|
|
88
|
+
const loadedPlugins: LoadedPlugin[] = [];
|
|
89
|
+
|
|
90
|
+
// Load all plugins first
|
|
91
|
+
for (const dir of pluginDirs) {
|
|
92
|
+
try {
|
|
93
|
+
const loaded = await this.loadPlugin(dir);
|
|
94
|
+
loadedPlugins.push(loaded);
|
|
95
|
+
} catch (error) {
|
|
96
|
+
console.error(`Failed to load plugin from ${dir}:`, error);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Resolve dependencies and initialize
|
|
101
|
+
const sortedPlugins = this.resolveDependencyOrder(loadedPlugins);
|
|
102
|
+
|
|
103
|
+
for (const loaded of sortedPlugins) {
|
|
104
|
+
await this.initializePlugin(loaded);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return sortedPlugins;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
private resolveDependencyOrder(plugins: LoadedPlugin[]): LoadedPlugin[] {
|
|
111
|
+
const sorted: LoadedPlugin[] = [];
|
|
112
|
+
const visited = new Set<string>();
|
|
113
|
+
const visiting = new Set<string>();
|
|
114
|
+
|
|
115
|
+
const visit = (plugin: LoadedPlugin) => {
|
|
116
|
+
const name = plugin.metadata.name;
|
|
117
|
+
|
|
118
|
+
if (visiting.has(name)) {
|
|
119
|
+
throw new Error(
|
|
120
|
+
`Circular dependency detected involving plugin: ${name}`
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (visited.has(name)) {
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
visiting.add(name);
|
|
129
|
+
|
|
130
|
+
// Visit dependencies first
|
|
131
|
+
const dependencies = plugin.metadata.dependencies || [];
|
|
132
|
+
for (const depName of dependencies) {
|
|
133
|
+
const dep = plugins.find((p) => p.metadata.name === depName);
|
|
134
|
+
if (!dep) {
|
|
135
|
+
throw new Error(
|
|
136
|
+
`Plugin ${name} depends on ${depName}, but it's not loaded`
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
visit(dep);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
visiting.delete(name);
|
|
143
|
+
visited.add(name);
|
|
144
|
+
sorted.push(plugin);
|
|
145
|
+
};
|
|
146
|
+
|
|
147
|
+
for (const plugin of plugins) {
|
|
148
|
+
visit(plugin);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return sorted;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private async initializePlugin(loaded: LoadedPlugin): Promise<void> {
|
|
155
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
156
|
+
await hookRegistry.trigger(LIFECYCLE_HOOKS.BEFORE_PLUGIN_INIT, loaded);
|
|
157
|
+
|
|
158
|
+
// Call plugin's onInit lifecycle method
|
|
159
|
+
if (loaded.plugin.onInit) {
|
|
160
|
+
await loaded.plugin.onInit();
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
await hookRegistry.trigger(LIFECYCLE_HOOKS.AFTER_PLUGIN_INIT, loaded);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
async unloadPlugin(pluginName: string): Promise<void> {
|
|
167
|
+
const loaded = this.loadedPlugins.get(pluginName);
|
|
168
|
+
if (!loaded) {
|
|
169
|
+
throw new Error(`Plugin ${pluginName} is not loaded`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
173
|
+
await hookRegistry.trigger(LIFECYCLE_HOOKS.BEFORE_PLUGIN_DESTROY, loaded);
|
|
174
|
+
|
|
175
|
+
// Call plugin's onDestroy lifecycle method
|
|
176
|
+
if (loaded.plugin.onDestroy) {
|
|
177
|
+
await loaded.plugin.onDestroy();
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
this.loadedPlugins.delete(pluginName);
|
|
181
|
+
|
|
182
|
+
await hookRegistry.trigger(LIFECYCLE_HOOKS.AFTER_PLUGIN_DESTROY, loaded);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
getPlugin(name: string): LoadedPlugin | undefined {
|
|
186
|
+
return this.loadedPlugins.get(name);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
getAllPlugins(): LoadedPlugin[] {
|
|
190
|
+
return Array.from(this.loadedPlugins.values());
|
|
191
|
+
}
|
|
192
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { describe, it, expect, beforeEach, afterEach } from 'bun:test';
|
|
2
|
+
import { PluginManager } from './PluginManager';
|
|
3
|
+
import { resolve } from 'path';
|
|
4
|
+
import { inject } from '@kanian77/simple-di';
|
|
5
|
+
import { PLUGIN_MANAGER_TOKEN } from '../lib/types/tokens';
|
|
6
|
+
|
|
7
|
+
describe('PluginManager', () => {
|
|
8
|
+
let manager: PluginManager;
|
|
9
|
+
const pluginsDir = resolve('src/lib/test-related/plugins');
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
manager = inject<PluginManager>(PLUGIN_MANAGER_TOKEN);
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
afterEach(async () => {
|
|
16
|
+
// No cleanup needed for static test plugins
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('initialize loads plugins and prints info', async () => {
|
|
20
|
+
let logOutput = '';
|
|
21
|
+
const origLog = console.log;
|
|
22
|
+
console.log = (msg: string) => {
|
|
23
|
+
logOutput += msg + '\n';
|
|
24
|
+
};
|
|
25
|
+
await manager.initialize(pluginsDir);
|
|
26
|
+
expect(logOutput).toContain('Loaded 2 plugins:');
|
|
27
|
+
expect(logOutput).toContain('plugin-a');
|
|
28
|
+
expect(logOutput).toContain('plugin-b');
|
|
29
|
+
console.log = origLog;
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('getPlugin returns correct plugin', async () => {
|
|
33
|
+
await manager.initialize(pluginsDir);
|
|
34
|
+
const pluginA = manager.getPlugin('pluginA');
|
|
35
|
+
expect(pluginA).toBeDefined();
|
|
36
|
+
expect(pluginA?.metadata.name).toBe('pluginA');
|
|
37
|
+
const pluginB = manager.getPlugin('pluginB');
|
|
38
|
+
expect(pluginB).toBeDefined();
|
|
39
|
+
expect(pluginB?.metadata.name).toBe('pluginB');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('getAllPlugins returns all loaded plugins', async () => {
|
|
43
|
+
await manager.initialize(pluginsDir);
|
|
44
|
+
const all = manager.getAllPlugins();
|
|
45
|
+
expect(all.length).toBe(2);
|
|
46
|
+
expect(all.map((p) => p.metadata.name).sort()).toEqual([
|
|
47
|
+
'pluginA',
|
|
48
|
+
'pluginB',
|
|
49
|
+
]);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('shutdown unloads all plugins and clears registry', async () => {
|
|
53
|
+
await manager.initialize(pluginsDir);
|
|
54
|
+
await manager.shutdown();
|
|
55
|
+
expect(manager.getAllPlugins()).toEqual([]);
|
|
56
|
+
});
|
|
57
|
+
});
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { Module, Service, Inject, inject } from '@kanian77/simple-di';
|
|
2
|
+
import { LIFECYCLE_HOOKS } from './HookRegistry';
|
|
3
|
+
import { PLUGIN_MANAGER_TOKEN, HOOK_REGISTRY_TOKEN } from '../lib/types/tokens';
|
|
4
|
+
import type { IHookRegistry, IPluginManager, LoadedPlugin } from '../lib/types';
|
|
5
|
+
import { PluginLoader } from './PluginLoader';
|
|
6
|
+
|
|
7
|
+
// Removed local interface IPluginManager, now imported from lib/types
|
|
8
|
+
|
|
9
|
+
@Service({ token: PLUGIN_MANAGER_TOKEN })
|
|
10
|
+
export class PluginManager implements IPluginManager {
|
|
11
|
+
constructor(
|
|
12
|
+
@Inject(HOOK_REGISTRY_TOKEN) private hookRegistry: IHookRegistry,
|
|
13
|
+
@Inject(PluginLoader) private loader: PluginLoader
|
|
14
|
+
) {}
|
|
15
|
+
|
|
16
|
+
async initialize(pluginsDir: string): Promise<void> {
|
|
17
|
+
await this.hookRegistry.trigger(LIFECYCLE_HOOKS.APPLICATION_START);
|
|
18
|
+
|
|
19
|
+
const plugins = await this.loader.loadPlugins(pluginsDir);
|
|
20
|
+
|
|
21
|
+
console.log(`Loaded ${plugins.length} plugins:`);
|
|
22
|
+
plugins.forEach((p) => {
|
|
23
|
+
console.log(` - ${p.metadata.name} v${p.metadata.version}`);
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
async shutdown(): Promise<void> {
|
|
28
|
+
await this.hookRegistry.trigger(LIFECYCLE_HOOKS.APPLICATION_SHUTDOWN);
|
|
29
|
+
|
|
30
|
+
const plugins = this.loader.getAllPlugins();
|
|
31
|
+
|
|
32
|
+
// Unload in reverse dependency order
|
|
33
|
+
for (const plugin of plugins.reverse()) {
|
|
34
|
+
await this.loader.unloadPlugin(plugin.metadata.name);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
this.hookRegistry.clear();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getPlugin(name: string): LoadedPlugin | undefined {
|
|
41
|
+
return this.loader.getPlugin(name);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
getAllPlugins(): LoadedPlugin[] {
|
|
45
|
+
return this.loader.getAllPlugins();
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { inject } from '@kanian77/simple-di';
|
|
2
|
+
import { HOOK_METADATA_KEY, HOOK_REGISTRY_TOKEN } from '../lib/types/tokens';
|
|
3
|
+
import type { IHookRegistry } from '../lib/types';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Decorator to register a method as a hook handler.
|
|
7
|
+
* This allows the method to be called when the specified hook is triggered.
|
|
8
|
+
* @param hookName The name of the hook to register this method for.
|
|
9
|
+
* @returns
|
|
10
|
+
*/
|
|
11
|
+
export function Hook(hookName: string) {
|
|
12
|
+
return function <T extends (...args: any[]) => any>(
|
|
13
|
+
target: any,
|
|
14
|
+
propertyKey: string | symbol,
|
|
15
|
+
descriptor: TypedPropertyDescriptor<T>
|
|
16
|
+
): TypedPropertyDescriptor<T> | void {
|
|
17
|
+
// Store hook metadata on the class prototype
|
|
18
|
+
if (!target.constructor[HOOK_METADATA_KEY]) {
|
|
19
|
+
target.constructor[HOOK_METADATA_KEY] = [];
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
target.constructor[HOOK_METADATA_KEY].push({
|
|
23
|
+
hookName,
|
|
24
|
+
methodName: propertyKey.toString(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Return the descriptor (or void, both are valid)
|
|
28
|
+
return descriptor;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export * from './hookDecorator';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { inject } from "@kanian77/simple-di";
|
|
2
|
+
import type { IHookRegistry } from "../types";
|
|
3
|
+
import { HOOK_REGISTRY_TOKEN } from "../types/tokens";
|
|
4
|
+
|
|
5
|
+
export function registerHookHandler(
|
|
6
|
+
hookName: string,
|
|
7
|
+
handler: (...args: any[]) => void | Promise<void>
|
|
8
|
+
) {
|
|
9
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
10
|
+
hookRegistry.register(hookName, handler);
|
|
11
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { inject } from "@kanian77/simple-di";
|
|
2
|
+
import type { IHookRegistry } from "../types";
|
|
3
|
+
import type { HookMetadata } from "../types/HookMetadata";
|
|
4
|
+
import { HOOK_METADATA_KEY, HOOK_REGISTRY_TOKEN } from "../types/tokens";
|
|
5
|
+
|
|
6
|
+
export function registerHooksForInstance(instance: any): void {
|
|
7
|
+
const constructor = instance.constructor;
|
|
8
|
+
const hookMetadata: HookMetadata[] = constructor[HOOK_METADATA_KEY] || [];
|
|
9
|
+
|
|
10
|
+
if (hookMetadata.length === 0) {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
try {
|
|
15
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
16
|
+
|
|
17
|
+
for (const { hookName, methodName } of hookMetadata) {
|
|
18
|
+
const method = instance[methodName];
|
|
19
|
+
if (typeof method === 'function') {
|
|
20
|
+
hookRegistry.register(hookName, method.bind(instance));
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
} catch (error) {
|
|
24
|
+
console.warn('Failed to register hooks - HookRegistry may not be available yet:', error);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { inject } from '@kanian77/simple-di';
|
|
2
|
+
import { ExampleService } from '../../../../../plugins/example-plugin/services';
|
|
3
|
+
import { Plugin } from '../../../../core';
|
|
4
|
+
import { Hook } from '../../../../decorators';
|
|
5
|
+
import type { IHookRegistry } from '../../../types';
|
|
6
|
+
import { HOOK_REGISTRY_TOKEN } from '../../../types/tokens';
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_A_TOKEN = 'PLUGIN_A_TOKEN';
|
|
9
|
+
export default class PluginA extends Plugin {
|
|
10
|
+
readonly metadata = {
|
|
11
|
+
name: 'pluginA',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
description: 'An example plugin',
|
|
14
|
+
dependencies: [], // other plugin names this depends on
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
// Call parent Module constructor with plugin configuration
|
|
19
|
+
super({
|
|
20
|
+
providers: [
|
|
21
|
+
{
|
|
22
|
+
provide: PLUGIN_A_TOKEN,
|
|
23
|
+
useClass: ExampleService,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override async onLoad(): Promise<void> {
|
|
30
|
+
console.log('PluginA loading...');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
override async onInit(): Promise<void> {
|
|
34
|
+
console.log('PluginA initialized!');
|
|
35
|
+
|
|
36
|
+
// Additional manual hook registration if needed
|
|
37
|
+
this.registerAdditionalHooks();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override async onDestroy(): Promise<void> {
|
|
41
|
+
console.log('PluginA destroyed!');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected override registerCustomHooks(): void {
|
|
45
|
+
console.log('Registering custom hooks for PluginA');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// These methods will be automatically registered as hook handlers
|
|
49
|
+
@Hook('custom:example-event')
|
|
50
|
+
private async handleExampleEvent(data: any): Promise<void> {
|
|
51
|
+
console.log('Handling example event via decorator:', data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Hook('core:application-start')
|
|
55
|
+
private async onApplicationStart(): Promise<void> {
|
|
56
|
+
console.log('PluginA responding to application start via decorator');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Hook('core:before-plugin-load')
|
|
60
|
+
private async onBeforePluginLoad(pluginPath: string): Promise<void> {
|
|
61
|
+
console.log('Another plugin is about to load:', pluginPath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private registerAdditionalHooks(): void {
|
|
65
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
66
|
+
hookRegistry.register(
|
|
67
|
+
'dynamic:custom-hook',
|
|
68
|
+
this.handleDynamicHook.bind(this)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
private async handleDynamicHook(data: any): Promise<void> {
|
|
72
|
+
console.log('Handling dynamic hook:', data);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { inject } from '@kanian77/simple-di';
|
|
2
|
+
import { ExampleService } from '../../../../../plugins/example-plugin/services';
|
|
3
|
+
import { Plugin } from '../../../../core';
|
|
4
|
+
import { Hook } from '../../../../decorators';
|
|
5
|
+
import type { IHookRegistry } from '../../../types';
|
|
6
|
+
import { HOOK_REGISTRY_TOKEN } from '../../../types/tokens';
|
|
7
|
+
|
|
8
|
+
export const PLUGIN_B_TOKEN = 'PLUGIN_B_TOKEN';
|
|
9
|
+
export default class PluginB extends Plugin {
|
|
10
|
+
readonly metadata = {
|
|
11
|
+
name: 'pluginB',
|
|
12
|
+
version: '1.0.0',
|
|
13
|
+
description: 'An example plugin',
|
|
14
|
+
dependencies: [], // other plugin names this depends on
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
constructor() {
|
|
18
|
+
// Call parent Module constructor with plugin configuration
|
|
19
|
+
super({
|
|
20
|
+
providers: [
|
|
21
|
+
{
|
|
22
|
+
provide: PLUGIN_B_TOKEN,
|
|
23
|
+
useClass: ExampleService,
|
|
24
|
+
},
|
|
25
|
+
],
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
override async onLoad(): Promise<void> {
|
|
30
|
+
console.log('PluginB loading...');
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
override async onInit(): Promise<void> {
|
|
34
|
+
console.log('PluginB initialized!');
|
|
35
|
+
|
|
36
|
+
// Additional manual hook registration if needed
|
|
37
|
+
this.registerAdditionalHooks();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override async onDestroy(): Promise<void> {
|
|
41
|
+
console.log('PluginB destroyed!');
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
protected override registerCustomHooks(): void {
|
|
45
|
+
console.log('Registering custom hooks for PluginB');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// These methods will be automatically registered as hook handlers
|
|
49
|
+
@Hook('custom:example-event')
|
|
50
|
+
private async handleExampleEvent(data: any): Promise<void> {
|
|
51
|
+
console.log('Handling example event via decorator:', data);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
@Hook('core:application-start')
|
|
55
|
+
private async onApplicationStart(): Promise<void> {
|
|
56
|
+
console.log('PluginB responding to application start via decorator');
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
@Hook('core:before-plugin-load')
|
|
60
|
+
private async onBeforePluginLoad(pluginPath: string): Promise<void> {
|
|
61
|
+
console.log('Another plugin is about to load:', pluginPath);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private registerAdditionalHooks(): void {
|
|
65
|
+
const hookRegistry = inject<IHookRegistry>(HOOK_REGISTRY_TOKEN);
|
|
66
|
+
hookRegistry.register(
|
|
67
|
+
'dynamic:custom-hook',
|
|
68
|
+
this.handleDynamicHook.bind(this)
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
private async handleDynamicHook(data: any): Promise<void> {
|
|
72
|
+
console.log('Handling dynamic hook:', data);
|
|
73
|
+
}
|
|
74
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
export type HookFn = (...args: any[]) => void | Promise<void>;
|
|
2
|
+
export type HookMap = Map<string, HookFn[]>;
|
|
3
|
+
|
|
4
|
+
export interface IHookRegistry {
|
|
5
|
+
register(name: string, fn: HookFn): void;
|
|
6
|
+
trigger(name: string, ...args: any[]): Promise<void>;
|
|
7
|
+
unregister(name: string, fn?: HookFn): void;
|
|
8
|
+
getHooks(name: string): HookFn[];
|
|
9
|
+
clear(): void;
|
|
10
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { ModuleOptions } from '@kanian77/simple-di';
|
|
2
|
+
import type { Plugin } from '../../core/Plugin';
|
|
3
|
+
import type { PluginMetadata } from './PluginMetadata';
|
|
4
|
+
|
|
5
|
+
export interface LoadedPlugin {
|
|
6
|
+
plugin: Plugin;
|
|
7
|
+
metadata: PluginMetadata;
|
|
8
|
+
pluginClass: new <T extends ModuleOptions = ModuleOptions>(options: T ) => Plugin;
|
|
9
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
// Environment setup & latest features
|
|
4
|
+
"lib": ["ESNext"],
|
|
5
|
+
"target": "ESNext",
|
|
6
|
+
"module": "Preserve",
|
|
7
|
+
"moduleDetection": "force",
|
|
8
|
+
"jsx": "react-jsx",
|
|
9
|
+
"allowJs": true,
|
|
10
|
+
|
|
11
|
+
// Bundler mode
|
|
12
|
+
"moduleResolution": "bundler",
|
|
13
|
+
"allowImportingTsExtensions": true,
|
|
14
|
+
"verbatimModuleSyntax": true,
|
|
15
|
+
"noEmit": true,
|
|
16
|
+
|
|
17
|
+
// Best practices
|
|
18
|
+
"strict": true,
|
|
19
|
+
"skipLibCheck": true,
|
|
20
|
+
"noFallthroughCasesInSwitch": true,
|
|
21
|
+
"noUncheckedIndexedAccess": true,
|
|
22
|
+
"noImplicitOverride": true,
|
|
23
|
+
|
|
24
|
+
// Some stricter flags (disabled by default)
|
|
25
|
+
"noUnusedLocals": false,
|
|
26
|
+
"noUnusedParameters": false,
|
|
27
|
+
"noPropertyAccessFromIndexSignature": false,
|
|
28
|
+
|
|
29
|
+
"experimentalDecorators": true,
|
|
30
|
+
"emitDecoratorMetadata": true
|
|
31
|
+
}
|
|
32
|
+
}
|