feat: inter-plugin discovery, targeted events, RPC — v1.4.0
This commit is contained in:
parent
11e1827031
commit
acc8c3178d
169
README.md
169
README.md
|
|
@ -173,6 +173,65 @@ const result = await coreService.callPluginApi('notifications', 'stats', { perio
|
||||||
const user = await coreService.callPluginApi('user-management', 'users/42');
|
const user = await coreService.callPluginApi('user-management', 'users/42');
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### `findPluginsByEvent(eventName)`
|
||||||
|
|
||||||
|
Знайти плагіни, які оголосили певну подію у своєму `manifest.json`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const listeners = await coreService.findPluginsByEvent('core.user.created');
|
||||||
|
// listeners = [ { id: 'audit-logs', name: 'Audit Logs', ... } ]
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `emitToPlugin(pluginId, pattern, payload)`
|
||||||
|
|
||||||
|
Надіслати подію конкретному плагіну через RabbitMQ. Публікує в `system_events` з routing key `plugin.<pluginId>.<pattern>`. Цільовий плагін отримує подію через `@EventPattern()`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Надіслати подію тільки плагіну notifications
|
||||||
|
await coreService.emitToPlugin('notifications', 'send', {
|
||||||
|
userId: 1,
|
||||||
|
title: 'New deal',
|
||||||
|
message: 'Deal #42 created',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Цільовий плагін обробляє через стандартний `@EventPattern`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@EventPattern('plugin.notifications.send')
|
||||||
|
async handle(@Payload() data: any) {
|
||||||
|
const event = data.data || data;
|
||||||
|
// event.payload = { userId: 1, title: 'New deal', ... }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `requestPlugin<T>(pluginId, pattern, payload, timeoutMs?)`
|
||||||
|
|
||||||
|
RPC-виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS RabbitMQ RPC (`send` / `@MessagePattern`).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const stats = await coreService.requestPlugin('notifications', 'getStats', {
|
||||||
|
period: 'week',
|
||||||
|
});
|
||||||
|
// stats = { sent: 42, failed: 1 }
|
||||||
|
```
|
||||||
|
|
||||||
|
Цільовий плагін обробляє через `@MessagePattern`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
@MessagePattern('plugin.notifications.getStats')
|
||||||
|
async getStats(@Payload() data: { period: string }) {
|
||||||
|
// логіка підрахунку...
|
||||||
|
return { sent: 42, failed: 1 };
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметри:
|
||||||
|
- `pluginId` — ID цільового плагіна
|
||||||
|
- `pattern` — назва команди (без префікса `plugin.<id>.`, він додається автоматично)
|
||||||
|
- `payload` — тіло запиту
|
||||||
|
- `timeoutMs` — таймаут (за замовчуванням 5000 мс). Повертає `null` при перевищенні.
|
||||||
|
|
||||||
#### `discoverPlugins()`
|
#### `discoverPlugins()`
|
||||||
|
|
||||||
Отримати список усіх зареєстрованих плагінів з їхніми можливостями. Повертає масив маніфестів з полями: `id`, `name`, `label`, `icon`, `routePath`, `backendUrl`, `events`, `objectTypes`, `permissions`, `roles`, `database`, `menu`, `headerActions`.
|
Отримати список усіх зареєстрованих плагінів з їхніми можливостями. Повертає масив маніфестів з полями: `id`, `name`, `label`, `icon`, `routePath`, `backendUrl`, `events`, `objectTypes`, `permissions`, `roles`, `database`, `menu`, `headerActions`.
|
||||||
|
|
@ -344,12 +403,22 @@ await coreClient.notifyCore('myplugin.event', { data: 'value' });
|
||||||
```typescript
|
```typescript
|
||||||
import { getRabbitMQOptions } from '@erp/plugin-sdk';
|
import { getRabbitMQOptions } from '@erp/plugin-sdk';
|
||||||
|
|
||||||
|
// Звичайне підключення — отримує всі події
|
||||||
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
|
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue'));
|
||||||
|
|
||||||
|
// З підтримкою targeted events — також байндить plugin.<id>.*
|
||||||
|
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||||
|
app.connectMicroservice(getRabbitMQOptions('plugin_my_queue', undefined, manifest.id));
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Параметри:
|
||||||
|
- `queueName` — назва черги
|
||||||
|
- `url?` — RabbitMQ URL (за замовчуванням з `RABBITMQ_URL` або `amqp://localhost:5672`)
|
||||||
|
- `pluginId?` — (v1.4.0+) ID плагіна для додаткового binding `plugin.<pluginId>.*` (targeted events)
|
||||||
|
|
||||||
Конфігурація:
|
Конфігурація:
|
||||||
- Exchange: `system_events` (topic)
|
- Exchange: `system_events` (topic)
|
||||||
- Routing key: `#` (отримує всі події)
|
- Routing keys: `#` (всі події) + `plugin.<id>.*` (якщо передано `pluginId`)
|
||||||
- Queue: вказана назва (durable)
|
- Queue: вказана назва (durable)
|
||||||
- Повідомлення: persistent
|
- Повідомлення: persistent
|
||||||
|
|
||||||
|
|
@ -619,19 +688,86 @@ Plugin A Core Backend Plugin B
|
||||||
2. **Визначає `backendUrl`** цільового плагіна з реєстру
|
2. **Визначає `backendUrl`** цільового плагіна з реєстру
|
||||||
3. **Проксує POST-запит** з автентифікацією та тілом
|
3. **Проксує POST-запит** з автентифікацією та тілом
|
||||||
|
|
||||||
### Discover — дізнатись які плагіни доступні
|
### Усі способи комунікації
|
||||||
|
|
||||||
|
| Метод | Тип | Канал | Адресат |
|
||||||
|
|-------|-----|-------|---------|
|
||||||
|
| `callPluginApi()` | Sync HTTP RPC | HTTP через core-proxy | Конкретний плагін |
|
||||||
|
| `emitToPlugin()` | Async event | RabbitMQ `plugin.<id>.<pattern>` | Конкретний плагін |
|
||||||
|
| `requestPlugin()` | Sync RPC | RabbitMQ `send/@MessagePattern` | Конкретний плагін |
|
||||||
|
| `emit()` | Async event | RabbitMQ broadcast | Всі плагіни |
|
||||||
|
|
||||||
|
### 1. Discovery
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
// Всі зареєстровані плагіни
|
||||||
const plugins = await coreService.discoverPlugins();
|
const plugins = await coreService.discoverPlugins();
|
||||||
|
|
||||||
|
// Знайти плагіни за подією
|
||||||
|
const listeners = await coreService.findPluginsByEvent('core.user.created');
|
||||||
```
|
```
|
||||||
|
|
||||||
Кожен плагін має:
|
Кожен плагін має:
|
||||||
- `id`, `name`, `label`, `backendUrl` — адреса для RPC
|
- `id`, `name`, `label`, `backendUrl` — адреса для HTTP RPC
|
||||||
- `events: { name, description }[]` — які події оголошує
|
- `events: { name, description }[]` — які події оголошує
|
||||||
- `objectTypes: string[]` — з якими об'єктами працює
|
- `objectTypes: string[]` — з якими об'єктами працює
|
||||||
- `permissions`, `roles` — права доступу
|
- `permissions`, `roles` — права доступу
|
||||||
|
|
||||||
### RPC — викликати API іншого плагіна
|
### 2. Targeted Events (RabbitMQ)
|
||||||
|
|
||||||
|
Надіслати подію конкретному плагіну через RabbitMQ. Подія публікується в `system_events` з routing key `plugin.<pluginId>.<pattern>`. Цільовий плагін має додатковий binding `plugin.<id>.*` (якщо передано `pluginId` у `getRabbitMQOptions`).
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Відправник
|
||||||
|
await coreService.emitToPlugin('notifications', 'send', {
|
||||||
|
userId: 1,
|
||||||
|
title: 'New deal',
|
||||||
|
message: 'Deal #42 created',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Отримувач (notifications plugin)
|
||||||
|
@EventPattern('plugin.notifications.send')
|
||||||
|
async handle(@Payload() data: any) {
|
||||||
|
const event = data.data || data;
|
||||||
|
console.log(event.payload.title); // 'New deal'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Підключення отримувача** (в `main.ts`):
|
||||||
|
```typescript
|
||||||
|
const manifest = JSON.parse(fs.readFileSync('manifest.json', 'utf8'));
|
||||||
|
app.connectMicroservice(
|
||||||
|
getRabbitMQOptions('plugin_notifications_queue', undefined, manifest.id)
|
||||||
|
);
|
||||||
|
// Черга байндиться з routing keys: # (всі події) + plugin.notifications.* (targeted)
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. RPC Request/Response (RabbitMQ)
|
||||||
|
|
||||||
|
Синхронний виклик до іншого плагіна з очікуванням відповіді. Використовує NestJS `send()` / `@MessagePattern()`.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Відправник
|
||||||
|
const stats = await coreService.requestPlugin('notifications', 'getStats', {
|
||||||
|
period: 'week',
|
||||||
|
});
|
||||||
|
console.log(stats); // { sent: 42, failed: 1 }
|
||||||
|
|
||||||
|
// Отримувач (notifications plugin)
|
||||||
|
@MessagePattern('plugin.notifications.getStats')
|
||||||
|
async getStats(@Payload() data: { period: string }) {
|
||||||
|
const stats = await this.calcStats(data.period);
|
||||||
|
return stats; // повертається відправнику
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Параметри `requestPlugin`:
|
||||||
|
- `pluginId` — ID цільового плагіна
|
||||||
|
- `pattern` — назва команди (без префікса)
|
||||||
|
- `payload` — тіло запиту
|
||||||
|
- `timeoutMs` — таймаут у мс (за замовчуванням 5000). `null` при перевищенні.
|
||||||
|
|
||||||
|
### 4. HTTP RPC через core-proxy
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
const stats = await coreService.callPluginApi('notifications', 'stats');
|
const stats = await coreService.callPluginApi('notifications', 'stats');
|
||||||
|
|
@ -643,32 +779,29 @@ const result = await coreService.callPluginApi('global-store', 'store/set', {
|
||||||
```
|
```
|
||||||
|
|
||||||
**Обмеження:**
|
**Обмеження:**
|
||||||
- Тільки POST-метод (ядро проксує POST на `backendUrl` цільового плагіна). Для GET-ендпоінтів цільового плагіна — використовуйте `@Post()` контролер з потрібною логікою.
|
- Тільки POST-метод
|
||||||
- Потребує JWT-токена (як і будь-який захищений API). Токен автоматично прокидyється з контексту запиту.
|
- Потребує JWT-токена (автоматично прокидyється з контексту)
|
||||||
- Якщо плагін не зареєстрований або не має `backendUrl` — повертається 404.
|
- Якщо плагін не зареєстрований або без `backendUrl` — 404
|
||||||
|
|
||||||
### Типовий сценарій використання
|
### Типовий сценарій
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
// В сервісі плагіна A
|
|
||||||
async function notifyAndLog() {
|
async function notifyAndLog() {
|
||||||
// 1. Discovery — знайти потрібний плагін
|
// 1. Discovery
|
||||||
const plugins = await this.coreService.discoverPlugins();
|
const [notifPlugin] = await this.coreService.findPluginsByEvent('notification.send');
|
||||||
const notifPlugin = plugins.find(p => p.events?.some(e => e.name === 'notification.send'));
|
|
||||||
|
|
||||||
// 2. RPC — викликати API плагіна B
|
// 2. Targeted event
|
||||||
if (notifPlugin) {
|
if (notifPlugin) {
|
||||||
await this.coreService.callPluginApi('notifications', 'send', {
|
await this.coreService.emitToPlugin('notifications', 'send', {
|
||||||
userId: 1,
|
userId: 1,
|
||||||
title: 'Event occurred',
|
title: 'Event occurred',
|
||||||
message: 'Plugin A triggered a notification',
|
message: 'Plugin A triggered a notification',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Або викликати будь-який кастомний ендпоінт
|
// 3. RPC запит до audit-logs
|
||||||
const status = await this.coreService.callPluginApi('audit-logs', 'stats/daily');
|
const status = await this.coreService.requestPlugin('audit-logs', 'dailyStats', {});
|
||||||
}
|
}
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -732,7 +865,7 @@ my-plugin/
|
||||||
|
|
||||||
```
|
```
|
||||||
@erp/plugin-sdk
|
@erp/plugin-sdk
|
||||||
├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, ...)
|
├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, findPluginsByEvent, emitToPlugin, requestPlugin, ...)
|
||||||
├── CoreClientService # RabbitMQ RPC клієнт
|
├── CoreClientService # RabbitMQ RPC клієнт
|
||||||
├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу
|
├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу
|
||||||
├── getRabbitMQOptions() # NestJS Microservice options
|
├── getRabbitMQOptions() # NestJS Microservice options
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "@erp/plugin-sdk",
|
"name": "@erp/plugin-sdk",
|
||||||
"version": "1.3.0",
|
"version": "1.4.0",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"types": "dist/index.d.ts",
|
"types": "dist/index.d.ts",
|
||||||
"bin": {
|
"bin": {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
import { Transport, MicroserviceOptions } from '@nestjs/microservices';
|
import { Transport, MicroserviceOptions, RmqOptions } from '@nestjs/microservices';
|
||||||
|
|
||||||
export const getRabbitMQOptions = (queueName: string, url?: string): MicroserviceOptions => {
|
export const getRabbitMQOptions = (queueName: string, url?: string, pluginId?: string): MicroserviceOptions => {
|
||||||
|
const routingKeys: string[] = ['#'];
|
||||||
|
if (pluginId) {
|
||||||
|
routingKeys.push(`plugin.${pluginId}.*`);
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
transport: Transport.RMQ,
|
transport: Transport.RMQ,
|
||||||
options: {
|
options: {
|
||||||
|
|
@ -8,12 +12,13 @@ export const getRabbitMQOptions = (queueName: string, url?: string): Microservic
|
||||||
queue: queueName,
|
queue: queueName,
|
||||||
exchange: 'system_events',
|
exchange: 'system_events',
|
||||||
exchangeType: 'topic',
|
exchangeType: 'topic',
|
||||||
routingKey: '#',
|
routingKey: routingKeys,
|
||||||
queueOptions: {
|
queueOptions: {
|
||||||
durable: true, // Черга не зникає при перезавантаженні RabbitMQ
|
durable: true,
|
||||||
},
|
|
||||||
persistent: true, // Повідомлення зберігаються на диску
|
|
||||||
},
|
},
|
||||||
|
persistent: true,
|
||||||
|
noAck: true,
|
||||||
|
} as RmqOptions['options'] & { routingKey: string[] },
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import { Injectable, Inject, Logger } from '@nestjs/common';
|
import { Injectable, Inject, Logger } from '@nestjs/common';
|
||||||
import { ClientProxy } from '@nestjs/microservices';
|
import { ClientProxy } from '@nestjs/microservices';
|
||||||
import { firstValueFrom } from 'rxjs';
|
import { firstValueFrom, timeout as rxTimeout } from 'rxjs';
|
||||||
import { SystemEvent } from '../interfaces/communication';
|
import { SystemEvent } from '../interfaces/communication';
|
||||||
import { v4 as uuidv4 } from 'uuid';
|
import { v4 as uuidv4 } from 'uuid';
|
||||||
import * as fs from 'fs';
|
import * as fs from 'fs';
|
||||||
|
|
@ -144,6 +144,77 @@ export class CoreService {
|
||||||
}, 30 * 1000); // 30 seconds
|
}, 30 * 1000); // 30 seconds
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Викликати API іншого плагіна через core RPC-проксі.
|
||||||
|
* Запит проксується ядром на backendUrl цільового плагіна методом POST.
|
||||||
|
*/
|
||||||
|
async callPluginApi<T = any>(pluginId: string, path: string, body?: any): Promise<T> {
|
||||||
|
return this.fetchCore(`/api/plugins/rpc/${pluginId}/${path.replace(/^\//, '')}`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Отримати список усіх зареєстрованих плагінів з їхніми events, objectTypes, backendUrl тощо.
|
||||||
|
* Корисно для discovery — дізнатись, які плагіни доступні та що вони вміють.
|
||||||
|
*/
|
||||||
|
async discoverPlugins(): Promise<any[]> {
|
||||||
|
const response = await this.fetchCore('/api/plugins/discovery');
|
||||||
|
return response.data || response || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Знайти плагіни, які оголосили певну подію у своєму manifest.json (поле events).
|
||||||
|
*/
|
||||||
|
async findPluginsByEvent(eventName: string): Promise<any[]> {
|
||||||
|
const plugins = await this.discoverPlugins();
|
||||||
|
return plugins.filter((p) =>
|
||||||
|
p.events?.some((e: { name: string }) => e.name === eventName),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Надіслати подію конкретному плагіну через RabbitMQ.
|
||||||
|
* Публікує в system_events з routingKey: plugin.<pluginId>.<pattern>.
|
||||||
|
* Цільовий плагін отримує подію через @EventPattern('plugin.<pluginId>.<pattern>')
|
||||||
|
* або більш загальний @EventPattern('plugin.*').
|
||||||
|
*
|
||||||
|
* Інші плагіни теж отримують подію (через # binding), але за конвенцією
|
||||||
|
* мають ігнорувати події з префіксом plugin.<чужойId>.
|
||||||
|
*/
|
||||||
|
async emitToPlugin(pluginId: string, pattern: string, payload: any): Promise<void> {
|
||||||
|
await this.emit(`plugin.${pluginId}.${pattern}`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* RPC-виклик до іншого плагіна з очікуванням відповіді.
|
||||||
|
* Використовує NestJS RabbitMQ RPC (send / @MessagePattern).
|
||||||
|
* Цільовий плагін має обробити запит через @MessagePattern('plugin.<pluginId>.<pattern>').
|
||||||
|
*
|
||||||
|
* @param pluginId — ID цільового плагіна
|
||||||
|
* @param pattern — назва команди
|
||||||
|
* @param payload — тіло запиту
|
||||||
|
* @param timeoutMs — таймаут очікування (за замовчуванням 5000 мс)
|
||||||
|
* @returns відповідь від цільового плагіна або null по таймауту
|
||||||
|
*/
|
||||||
|
async requestPlugin<T = any>(
|
||||||
|
pluginId: string,
|
||||||
|
pattern: string,
|
||||||
|
payload: any,
|
||||||
|
timeoutMs: number = 5000,
|
||||||
|
): Promise<T | null> {
|
||||||
|
try {
|
||||||
|
return await firstValueFrom(
|
||||||
|
this.client.send(`plugin.${pluginId}.${pattern}`, payload).pipe(rxTimeout(timeoutMs)),
|
||||||
|
{ defaultValue: null },
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
this.logger.error(`[requestPlugin] failed for ${pluginId}/${pattern}: ${err.message}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async emit<T>(pattern: string, payload: T): Promise<void> {
|
async emit<T>(pattern: string, payload: T): Promise<void> {
|
||||||
const event: SystemEvent<T> = {
|
const event: SystemEvent<T> = {
|
||||||
eventId: uuidv4(),
|
eventId: uuidv4(),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue