feat: inter-plugin discovery, targeted events, RPC — v1.4.0

This commit is contained in:
Кирилл Осипков 2026-05-28 09:32:27 +03:00
parent 11e1827031
commit acc8c3178d
4 changed files with 235 additions and 26 deletions

169
README.md
View File

@ -173,6 +173,65 @@ const result = await coreService.callPluginApi('notifications', 'stats', { perio
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()`
Отримати список усіх зареєстрованих плагінів з їхніми можливостями. Повертає масив маніфестів з полями: `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
import { getRabbitMQOptions } from '@erp/plugin-sdk';
// Звичайне підключення — отримує всі події
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)
- Routing key: `#` (отримує всі події)
- Routing keys: `#` (всі події) + `plugin.<id>.*` (якщо передано `pluginId`)
- Queue: вказана назва (durable)
- Повідомлення: persistent
@ -619,19 +688,86 @@ Plugin A Core Backend Plugin B
2. **Визначає `backendUrl`** цільового плагіна з реєстру
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
// Всі зареєстровані плагіни
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 }[]` — які події оголошує
- `objectTypes: string[]` — з якими об'єктами працює
- `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
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()` контролер з потрібною логікою.
- Потребує JWT-токена (як і будь-який захищений API). Токен автоматично прокидyється з контексту запиту.
- Якщо плагін не зареєстрований або не має `backendUrl` — повертається 404.
- Тільки POST-метод
- Потребує JWT-токена (автоматично прокидyється з контексту)
- Якщо плагін не зареєстрований або без `backendUrl` — 404
### Типовий сценарій використання
### Типовий сценарій
```typescript
// В сервісі плагіна A
async function notifyAndLog() {
// 1. Discovery — знайти потрібний плагін
const plugins = await this.coreService.discoverPlugins();
const notifPlugin = plugins.find(p => p.events?.some(e => e.name === 'notification.send'));
// 1. Discovery
const [notifPlugin] = await this.coreService.findPluginsByEvent('notification.send');
// 2. RPC — викликати API плагіна B
// 2. Targeted event
if (notifPlugin) {
await this.coreService.callPluginApi('notifications', 'send', {
await this.coreService.emitToPlugin('notifications', 'send', {
userId: 1,
title: 'Event occurred',
message: 'Plugin A triggered a notification',
});
}
// 3. Або викликати будь-який кастомний ендпоінт
const status = await this.coreService.callPluginApi('audit-logs', 'stats/daily');
// 3. RPC запит до audit-logs
const status = await this.coreService.requestPlugin('audit-logs', 'dailyStats', {});
}
```
---
@ -732,7 +865,7 @@ my-plugin/
```
@erp/plugin-sdk
├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, ...)
├── CoreService # Головний сервіс (fetchCore, emit, register, callPluginApi, discoverPlugins, findPluginsByEvent, emitToPlugin, requestPlugin, ...)
├── CoreClientService # RabbitMQ RPC клієнт
├── AttributeAccessService # ABAC — керування атрибутами та перевірка доступу
├── getRabbitMQOptions() # NestJS Microservice options

View File

@ -1,6 +1,6 @@
{
"name": "@erp/plugin-sdk",
"version": "1.3.0",
"version": "1.4.0",
"main": "dist/index.js",
"types": "dist/index.d.ts",
"bin": {

View File

@ -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 {
transport: Transport.RMQ,
options: {
@ -8,12 +12,13 @@ export const getRabbitMQOptions = (queueName: string, url?: string): Microservic
queue: queueName,
exchange: 'system_events',
exchangeType: 'topic',
routingKey: '#',
routingKey: routingKeys,
queueOptions: {
durable: true, // Черга не зникає при перезавантаженні RabbitMQ
durable: true,
},
persistent: true, // Повідомлення зберігаються на диску
},
persistent: true,
noAck: true,
} as RmqOptions['options'] & { routingKey: string[] },
};
};

View File

@ -1,6 +1,6 @@
import { Injectable, Inject, Logger } from '@nestjs/common';
import { ClientProxy } from '@nestjs/microservices';
import { firstValueFrom } from 'rxjs';
import { firstValueFrom, timeout as rxTimeout } from 'rxjs';
import { SystemEvent } from '../interfaces/communication';
import { v4 as uuidv4 } from 'uuid';
import * as fs from 'fs';
@ -144,6 +144,77 @@ export class CoreService {
}, 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> {
const event: SystemEvent<T> = {
eventId: uuidv4(),