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');
|
||||
```
|
||||
|
||||
#### `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
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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[] },
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in New Issue