2026-03-16 22:34:17 +03:00
# Система модулей
## Обзор
2026-06-26 15:56:15 +03:00
Модуль — изолированная директория в `src/Modules/` с известным контрактом. Удаление модуля **не ломает систему** — она продолжает работать, деградируя в функциональности.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
Система построена на принципах **Extensible Platform** :
2026-03-16 22:34:17 +03:00
2026-06-26 15:56:15 +03:00
- Ядро (`Core/` ) ничего не знает о модулях
2026-06-14 14:25:19 +03:00
- Модули расширяют ядро через интерфейсы-контракты
- Никакой правки файлов ядра, никакого eval, никакого monkey patching
- Любой модуль отключается через `config/modules.php` без последствий для ядра
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Структура директории модуля
2026-03-16 22:34:17 +03:00
2026-07-03 20:12:01 +03:00
Имя директории — по конвенции ** `{name}_{hash5}` **, где `hash5` — первые 5 символов
`hash_id` . Логическое имя (`module.json` `name` , в нём никогда нет `_` ) всегда берётся из
манифеста, а не из имени папки. Благодаря этому два модуля с **одинаковым именем** живут в
разных папках (`watch_2541a` , `watch_9f1c0` ) и ставятся без конфликта на ФС. Конфиг, граф
зависимостей и namespace ключуются по каноничному `name` , поэтому переименование папки не
требует миграции данных. У модуля **обязан** быть `hash_id` : если загружаемый модуль пришёл без
него, id генерируется и записывается в `module.json` до размещения — папка без хеша не создаётся
никогда. Голая папка `Modules/{name}/` из старого развёртывания ещё читается, но **автоматически
мигрирует** в `{name}_{hash5}` (с генерацией `hash_id` , если его нет) при следующем
`console.php status` — старый формат не сохраняется, а вытесняется.
2026-03-16 22:34:17 +03:00
```
2026-06-14 14:25:19 +03:00
modules/
2026-07-03 20:12:01 +03:00
└── my-module_9f1c0/ # {name}_{hash5}; каноничное имя — "my-module"
2026-06-14 14:25:19 +03:00
├── module.json # Метаданные + поля загрузки
├── MyModule.php # Главный класс (implements ModuleInterface)
├── MyService.php # Сервисы модуля
├── MyController.php # Контроллер (если есть страницы)
├── MyCron.php # Крон-логика
├── MyCronJob.php # CLI-обёртка (implements CommandInterface)
├── MyStreamMiddleware.php # Stream-middleware (опционально)
2026-07-02 21:21:19 +03:00
├── database.sql # Мастер-схема — полный текущий CREATE/seed (опц.)
├── database_drop.sql # Удаление — DROP всех таблиц модуля (опц.)
├── migrations/ # Дельты между версиями (опц.)
│ └── 1.1.0.sql # Применяется только при апгрейде выше 1.1.0
└── views/
├── my_page.php
└── my_page_scripts.php
```
Модуль владеет своей схемой через **три роли — зеркало ядра** (`bin/install/database.sql`
+ `migrations/` ):
| Файл | Роль | Когда выполняется |
| ---- | ---- | ----------------- |
| `database.sql` | **Одна** мастер-схема — полный текущий `CREATE` /seed | свежая **установка** |
| `database_drop.sql` | **Один** файл удаления — `DROP TABLE` всех таблиц модуля | **удаление** |
| `migrations/<semver>.sql` | **Папка** форвардных дельт между версиями | **обновление** , для версий в `(installed, current]` |
Правила:
- **Свежая установка выполняет только `database.sql` **, поэтому он всегда должен отражать
ПОСЛЕДНЮЮ схему (все дельты уже влиты). Watermark `installed_version` гарантирует, что
дельты не проигрываются повторно на свежей установке.
- **Дельты только форвардные** (`ALTER` /`INSERT` ), имя `<semver>.sql` — удаление одно
(`database_drop.sql` ), поэтому пофайловых `.down` больше нет.
- Держите дельты **идемпотентными** (`ADD COLUMN IF NOT EXISTS` , `INSERT IGNORE` ).
- Модуль без схемы не поставляет эти файлы. Модуль только с дельтами (без `database.sql` )
всё равно установится, проиграв все дельты ≤ своей версии.
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Манифест `module.json`
2026-03-16 22:34:17 +03:00
```json
{
"name" : "my-module" ,
2026-07-03 20:12:01 +03:00
"hash_id" : "9f1c0b7e4d2a6538c1e0a4b7d6f39e21" ,
2026-06-14 14:25:19 +03:00
"description" : "Краткое описание модуля" ,
2026-03-16 22:34:17 +03:00
"version" : "1.0.0" ,
2026-05-06 21:53:37 +03:00
"requires_core" : ">=2.0" ,
"environment" : "main" ,
"dependencies" : [],
2026-06-14 14:25:19 +03:00
"optional_dependencies" : [],
2026-05-06 21:53:37 +03:00
"has_navbar" : false ,
2026-06-14 14:25:19 +03:00
"has_settings" : false ,
"priority" : 0
2026-03-16 22:34:17 +03:00
}
```
### Поля манифеста
2026-06-14 14:25:19 +03:00
| Поле | Тип | По умолчанию | Описание |
| ------ | ----- | :---: | ------------ |
2026-07-03 20:12:01 +03:00
| `name` | `string` | — | Каноничное имя (kebab-case, без `_` ). Директория — `{name}_{hash5}` , но код всегда ключуется по этому значению манифеста, а не по имени папки. |
| `hash_id` | `string` | генерируется | **Постоянная** идентичность модуля — случайный 32-hex, генерируется ОДИН раз и не меняется при смене версии/переименовании. Первые 5 символов образуют суффикс папки `{name}_{hash5}` . Руками не писать. |
2026-06-14 14:25:19 +03:00
| `description` | `string` | `""` | Краткое человекочитаемое описание |
| `version` | `string` | — | Semver-версия (`1.0.0` ) |
| `requires_core` | `string` | — | Минимальная версия ядра (`>=2.0` ) |
| `environment` | `string` | `"main"` | `main` — основной сервер, `lb` — load-balancer, `any` — оба |
2026-06-30 22:45:15 +03:00
| `dependencies` | `array` | `[]` | Обязательные зависимости: при недоступности зависимый модуль пропускается (см. ниже) |
2026-06-14 14:25:19 +03:00
| `optional_dependencies` | `array` | `[]` | Мягкие зависимости: при отсутствии модуль загружается без них |
| `has_navbar` | `bool` | `false` | Есть ли пункты навбара |
| `has_settings` | `bool` | `false` | Есть ли страница настроек |
| `priority` | `int` | `0` | Приоритет загрузки: выше значение — раньше загрузится (при топологически равном положении) |
2026-07-03 20:12:01 +03:00
> **`hash_id` — постоянная идентичность модуля.** Случайный 32-hex, генерируется **один раз**
> и **никогда** не меняется — переживает смену версии и переименование (потому случайный, не
2026-08-07 20:49:36 +03:00
> производный от `name`/`version`). Сгенерировать: `php -r 'echo bin2hex(random_bytes(16));'`
> и вставить в `module.json` при создании модуля. Руками не писать и не переиспользовать чужой. Даёт стабильную идентичность независимо от `name` — основа
2026-07-03 20:12:01 +03:00
> для выноса модулей в отдельные репозитории и явного **источника обновлений** — блок `update` в
> манифесте (ниже).
2026-06-14 14:25:19 +03:00
### Разница между `dependencies` и `optional_dependencies`
2026-05-06 21:53:37 +03:00
```json
{
2026-06-14 14:25:19 +03:00
"dependencies" : [ "tmdb" ],
"optional_dependencies" : [ "plex" ]
2026-05-06 21:53:37 +03:00
}
```
2026-06-30 22:45:15 +03:00
- `dependencies` : модуль `tmdb` **обязан** быть загружен до `my-module` . Если `tmdb` недоступен (отсутствует на диске, отключён или в состоянии `failed` ), то `my-module` **пропускается** с предупреждением в лог — каскадно (всё, что зависит от `my-module` , тоже пропустится). Загрузка остальных модулей и работа панели/CLI при этом **не прерывается** (см. [«Как работает загрузка» ](#как-работает-загрузка )).
2026-06-14 14:25:19 +03:00
- `optional_dependencies` : если `plex` присутствует — он загрузится **до** `my-module` . Если отсутствует — загрузка продолжается без него.
2026-06-30 22:45:15 +03:00
> **Защита от рассинхрона.** Отключить (`disabled`) модуль, от которого зависят **включённые** модули, через панель/`ModuleManager::setState()` нельзя — операция будет отклонена с пояснением, какие модули его требуют (по аналогии с запретом удаления `uninstallModule()`). Это не даёт создать состояние «`plex` включён, а его зависимость `watch` выключена».
2026-06-14 14:25:19 +03:00
### Приоритет загрузки
При топологически равных позициях (нет зависимости друг от друга), модули с бо́льшим `priority` загружаются и бутятся первыми.
2026-05-06 21:53:37 +03:00
```json
2026-06-14 14:25:19 +03:00
{ "name" : "auth-guard" , "priority" : 100 } ← загрузится первым
{ "name" : "tmdb" , "priority" : 50 } ← второй
{ "name" : "watch" , "priority" : 0 } ← третий (по умолчанию)
2026-05-06 21:53:37 +03:00
```
2026-06-14 14:25:19 +03:00
При равных `priority` — алфавитный порядок (детерминированность).
2026-07-03 20:12:01 +03:00
### Источник обновлений (блок `update`, опционально)
Откуда модуль берёт обновления. Отсутствует → `bundled` (файлы приходят с панелью и обновляются вместе с ней).
```json
"update" : {
"source" : "bundled | platform | git | url" ,
"repository" : "https://github.com/Vateron-Media/xc_vm-module-watch" ,
"channel" : "stable" ,
"slug" : "watch" ,
"url" : "https://…/version.json"
}
```
- `source` — `bundled` (с панелью), `platform` (SaaS-стор), `git` (релизы репо), `url` (самохостинг). Неизвестное значение → `bundled` .
- `repository` — git-remote (для `git` ); `slug` — slug в сторе (для `platform` , по умолчанию = `name` ); `url` — URL версии/архива (для `url` ); `channel` — `stable` /`beta` (по умолчанию `stable` ).
Блок нормализуется в `ModuleLoader` и доступен через `ModuleManager::listModules()` . Еженедельный крон (`cron:module_updates` ) проверяет источники `git` /`url` и записывает `available_version` — по нему показывается кнопка **Update to X** . Клик по Update вызывает `ModuleManager::updateModuleFromSource()` :
- `bundled` — файлы приходят с панелью; Update просто гоняет ожидающие миграции.
- `platform` — делегируется в стор-флоу (внутри откат + рассылка на LB).
- `git` — качает релиз-ассет ** `module.tar.gz` ** тега == новой версии (md5-верификация через `hashes.md5` релиза, если есть).
- `url` — перечитывает `version.json` за `download` (https) + опц. `md5` .
Для `git` /`url` `hash_id` из скачанного `module.json` **должен совпасть с установленным** (identity pinning — репо/URL не подменит чужой модуль), затем: бэкап → замена файлов → миграции → **откат при любой ошибке** → рассылка на LB.
**Стандартный набор и провизия.** Модули, которые панель ставит по умолчанию, перечислены в `config/bundled_modules.php` , ключ — `hash_id` (стабилен при переименовании). Сейчас все `bundled` (файлы в архиве панели). Когда модуль вынесут в отдельный репозиторий — переключаешь его запись на источник `git` /`url` /`platform` , и `syncBundledModules()` автоматически скачает + установит его через `provisionStandardSet()` (пока всё bundled на диске — no-op). `ModuleManager::findModuleByHashId()` находит модуль по стабильному id независимо от директории/имени.
2026-06-14 14:25:19 +03:00
---
## Интерфейсы модуля
`ModuleInterface` — составной интерфейс, объединяющий 4 суб-интерфейса:
2026-05-06 21:53:37 +03:00
```
2026-06-14 14:25:19 +03:00
ModuleInterface
├── ServiceProviderInterface boot(), getEventSubscribers()
├── RouteProviderInterface registerRoutes()
├── CommandProviderInterface registerCommands()
└── NavbarProviderInterface registerNavbar()
+ getName(), getVersion(), install(), uninstall()
```
Пятый суб-интерфейс — **опциональный** , не входит в `ModuleInterface` :
2026-05-06 21:53:37 +03:00
2026-06-14 14:25:19 +03:00
```
StreamMiddlewareProviderInterface getStreamMiddleware()
```
2026-05-06 21:53:37 +03:00
2026-06-14 14:25:19 +03:00
Модуль реализует его дополнительно, если хочет участвовать в стрим-pipeline.
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Класс модуля
2026-03-16 22:34:17 +03:00
2026-06-26 15:56:15 +03:00
Файл `src/Modules/my-module/MyModule.php` .
2026-06-15 18:05:27 +03:00
Расширяйте `BaseModule` — он предоставляет пустые реализации по умолчанию для всех
необязательных методов. Обязательны только `getName()` и `getVersion()` .
2026-03-16 22:34:17 +03:00
```php
<? php
2026-06-15 18:27:23 +03:00
namespace XcVm\Module\MyModule ;
2026-03-16 22:34:17 +03:00
2026-06-15 18:27:23 +03:00
use BaseModule ;
use ServiceContainer ;
use Router ;
use CommandRegistry ;
class MyModuleModule extends BaseModule {
2026-06-14 14:25:19 +03:00
2026-03-16 22:34:17 +03:00
public function getName () : string {
return 'my-module' ;
}
public function getVersion () : string {
return '1.0.0' ;
}
public function boot ( ServiceContainer $container ) : void {
2026-06-14 14:25:19 +03:00
$container -> set ( 'my-module.service' , function ( ServiceContainer $c ) {
2026-06-15 18:27:23 +03:00
return new MyModuleService ( $c -> get ( 'db' ));
2026-06-14 14:25:19 +03:00
});
}
public function getEventSubscribers () : array {
return [
2026-06-15 18:27:23 +03:00
StreamStartedEvent :: class => [ MyModuleHandler :: class , 'onStreamStarted' ],
2026-06-14 14:25:19 +03:00
// С приоритетом: [callable, int]
2026-06-15 18:27:23 +03:00
UserAuthenticatedEvent :: class => [[ MyModuleHandler :: class , 'onAuth' ], 20 ],
2026-06-14 14:25:19 +03:00
];
2026-03-16 22:34:17 +03:00
}
public function registerRoutes ( Router $router ) : void {
2026-06-15 18:27:23 +03:00
$router -> get ( 'my-module' , [ MyModuleController :: class , 'index' ], [
2026-03-16 22:34:17 +03:00
'permission' => [ 'adv' , 'my_module' ],
]);
2026-06-15 18:27:23 +03:00
$router -> api ( 'my_action' , [ MyModuleController :: class , 'apiAction' ], [
2026-03-16 22:34:17 +03:00
'permission' => [ 'adv' , 'my_module' ],
]);
}
public function registerCommands ( CommandRegistry $registry ) : void {
2026-06-15 18:27:23 +03:00
$registry -> register ( new MyModuleCronJob ());
2026-03-16 22:34:17 +03:00
}
2026-06-14 14:25:19 +03:00
public function registerNavbar () : void {
NavbarRegistry :: add (( new NavbarItem ( 'management.service_setup.my_module' ))
-> parent ( 'management.service_setup' )
-> url ( 'my_module' )
-> label ( 'my_module' )
-> permissions ([ 'my_module' ])
-> order ( 60 ));
2026-03-16 22:34:17 +03:00
}
2026-06-15 18:05:27 +03:00
// override install()/uninstall() only if migrations or cleanup are needed
2026-06-14 14:25:19 +03:00
}
```
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
### Контракт методов
| Метод | Интерфейс | Описание |
| ------- | ----------- | ---------- |
| `getName(): string` | `ModuleInterface` | Уникальное имя (совпадает с директорией) |
| `getVersion(): string` | `ModuleInterface` | Semver-версия |
| `install(): void` | `ModuleInterface` | Вызывается при установке из Marketplace |
| `uninstall(): void` | `ModuleInterface` | Вызывается при удалении |
| `boot(ServiceContainer)` | `ServiceProviderInterface` | Регистрация сервисов в DI-контейнере |
| `getEventSubscribers(): array` | `ServiceProviderInterface` | Подписки на типизированные события PSR-14 |
| `registerRoutes(Router)` | `RouteProviderInterface` | HTTP-маршруты и API-экшены |
| `registerCommands(CommandRegistry)` | `CommandProviderInterface` | Явная регистрация CLI-команд и крон-задач |
| `registerNavbar(): void` | `NavbarProviderInterface` | Пункты меню в admin navbar |
2026-07-02 21:21:19 +03:00
> **Важно — версия задаётся в двух местах.** Модуль объявляет свою версию **дважды**:
> поле `"version"` в `module.json` и возвращаемое значение `getVersion()` в классе
> модуля. **Держите их одинаковыми и повышайте обе перед публикацией.** В рантайме
> приоритет у версии из манифеста — установка/обновление и watermark
> `installed_version` сначала читают `module.json` и лишь потом откатываются к
> `getVersion()`, поэтому устаревший `getVersion()` тихо рассинхронизируется и
> становится частой причиной багов «не та миграция выполнилась / не выполнилась».
> Если модуль поставляет файловую схему, `database.sql` (мастер) и старшая дельта
> `migrations/<semver>.sql` тоже должны совпадать с этой версией.
2026-06-14 14:25:19 +03:00
---
2026-06-15 18:27:23 +03:00
## PHP-пространства имён
Каждый модуль живёт в своём PHP-пространстве имён: `XcVm\Module\{Pascal}` , где `{Pascal}` —
PascalCase-вариант имени директории модуля.
```
2026-06-26 15:56:15 +03:00
src/Modules/my-module/ → namespace XcVm\Module\MyModule;
src/Modules/watch/ → namespace XcVm\Module\Watch;
2026-06-15 18:27:23 +03:00
```
Главный файл модуля обязан объявлять это пространство имён и расширять `BaseModule` :
2026-06-15 18:05:27 +03:00
2026-06-15 18:27:23 +03:00
```php
<? php
namespace XcVm\Module\MyModule ;
2026-06-15 18:05:27 +03:00
2026-06-15 18:27:23 +03:00
use BaseModule ;
use ServiceContainer ;
class MyModuleModule extends BaseModule {
// ...
}
```
Все вспомогательные классы в том же модуле разделяют одно пространство имён:
```php
<? php
namespace XcVm\Module\MyModule ;
class MyModuleService { /* ... */ }
class MyModuleController { /* ... */ }
class MyModuleCronJob { /* ... */ }
```
Для каждого используемого класса ядра добавляйте `use` :
```php
namespace XcVm\Module\MyModule ;
use BaseModule ;
use ServiceContainer ;
use NavbarRegistry ;
use NavbarItem ;
```
2026-06-15 18:05:27 +03:00
**Правила:**
2026-06-15 18:27:23 +03:00
- Имя файла главного класса: `<PascalName>Module.php` — обязательно (соглашение ModuleLoader)
- Имена остальных файлов: `<PascalName><Purpose>.php`
- Добавляйте `use` для каждого класса ядра, на который есть ссылка
- Никогда не импортируйте классы из других модулей — общайтесь через события или DI-контейнер
2026-06-15 18:05:27 +03:00
---
2026-06-14 14:25:19 +03:00
## DI-контейнер и декорирование сервисов
### Регистрация сервисов
```php
public function boot ( ServiceContainer $container ) : void {
// Ленивая фабрика (singleton)
$container -> set ( 'my-module.service' , function ( ServiceContainer $c ) {
return new MyService ( $c -> get ( 'db' ), $c -> get ( 'settings' ));
});
// Фабричный сервис (новый экземпляр при каждом get)
$container -> factory ( 'my-module.request' , function ( ServiceContainer $c ) {
return new MyRequest ( $_GET , $_POST );
});
}
```
### Декорирование чужих сервисов
Модуль может обернуть любой незащищённый сервис декоратором без правки его кода:
```php
public function boot ( ServiceContainer $container ) : void {
$container -> decorate (
'stream.service' ,
MyLoggingDecorator :: class , // class-string: new Decorator($inner)
priority : 20
);
// Или callable-форма
$container -> decorate ( 'stream.service' , function ( $inner , ServiceContainer $c ) {
return new MyLoggingDecorator ( $inner , $c -> get ( 'logger' ));
}, priority : 20 );
2026-03-16 22:34:17 +03:00
}
```
2026-06-14 14:25:19 +03:00
**Защищённые сервисы** — декорировать нельзя: `db` , `settings` , `config` , `auth` .
Попытка задекорировать защищённый сервис выбросит `RuntimeException` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Порядок применения декораторов:** наибольший `priority` = самый внешний слой (вызывается первым).
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## PSR-14 События
Система событий — типизированные классы, а не строки.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
### Подписка на события
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
В `getEventSubscribers()` возвращайте карту `EventClass::class → callable` :
2026-03-16 22:34:17 +03:00
```php
2026-06-14 14:25:19 +03:00
public function getEventSubscribers () : array {
return [
// Простой callable
StreamStartedEvent :: class => [ MyHandler :: class , 'onStreamStarted' ],
StreamStoppedEvent :: class => [ MyHandler :: class , 'onStreamStopped' ],
// С приоритетом: [callable, int] — больше приоритет = вызывается раньше
UserAuthenticatedEvent :: class => [
[ MyHandler :: class , 'onAuth' ],
50
],
// Замыкание
SettingsChangedEvent :: class => function ( SettingsChangedEvent $e ) : void {
if ( in_array ( 'my_setting' , $e -> changedKeys ())) {
MyCache :: flush ();
}
},
];
}
```
### Диспетчеризация событий из модуля
```php
use EventDispatcher ;
EventDispatcher :: dispatch ( new PackageInstalledEvent (
slug : 'my-module' ,
version : '1.0.0' ,
path : '/path/to/module' ,
installedAt : time (),
));
2026-03-16 22:34:17 +03:00
```
2026-06-14 14:25:19 +03:00
### Прерываемые события (StoppableEventInterface)
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
Если слушатель вызвал `$event->stopPropagation()` , остальные слушатели **не вызываются** .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
```php
EventDispatcher :: listen ( StreamStartingEvent :: class , function ( StreamStartingEvent $e ) : void {
if ( $this -> isBlocked ( $e )) {
$e -> abort ( 'blocked by my-module' ); // специфичен для StreamStartingEvent
$e -> stopPropagation ();
}
});
```
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
### Встроенные события ядра
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
| Класс события | Когда диспетчеризуется | Прерываемое |
| --------------- | ---------------------- | :-----------: |
| `ModuleLoadedEvent` | После успешной загрузки файла модуля | ❌ |
| `ModuleBootedEvent` | После вызова `boot()` у модуля | ❌ |
| `PackageInstalledEvent` | После установки через Marketplace | ❌ |
| `UserAuthenticatedEvent` | Успешная аутентификация | ❌ |
| `UserLoggedOutEvent` | Выход пользователя | ❌ |
| `StreamStartingEvent` | Перед запуском стрима | ✅ |
| `StreamStartedEvent` | Стрим успешно запущен | ❌ |
| `StreamStoppedEvent` | Стрим остановлен | ❌ |
| `SettingsChangedEvent` | Изменение настроек панели | ❌ |
2026-03-18 20:33:25 +03:00
2026-06-26 15:56:15 +03:00
Все типизированные события находятся в `src/Core/Events/` .
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## Stream Middleware (опционально)
Если модуль хочет участвовать в обработке стрим-запросов, он реализует `StreamMiddlewareProviderInterface` (не входит в `ModuleInterface` ):
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
```php
class MyModule implements ModuleInterface , StreamMiddlewareProviderInterface {
// ... обязательные методы ModuleInterface ...
public function getStreamMiddleware () : array {
return [
new MyAuthMiddleware (),
new MyTheftDetectionMiddleware (),
];
}
}
```
### Реализация middleware
```php
class MyTheftDetectionMiddleware implements StreamMiddlewareInterface {
public function handle ( StreamContext $ctx , callable $next ) : StreamContext {
if ( $this -> isTheft ( $ctx )) {
$ctx -> abort ( 'theft detected' , 403 );
return $ctx ; // pipeline останавливается
}
// Сохранить данные в context
$ctx -> set ( 'my-module.fingerprint' , $this -> getFingerprint ( $ctx ));
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
return $next ( $ctx ); // передать управление следующему
}
public function getPriority () : int {
return 60 ; // core: 80-100, modules: 0-79, terminal: -1
}
}
```
### Приоритеты в pipeline
| Диапазон | Кому принадлежит |
| ---------- | ----------------- |
| `80– 100` | Ядро (Auth, Permission, ConnectionLimit) |
| `0– 79` | Модули |
| `-1` | Terminal middleware (финальное выполнение стрима) |
### StreamContext
```php
// Прочитать параметры запроса
$streamId = $ctx -> get ( 'stream_id' );
$userId = $ctx -> get ( 'user_id' );
// Записать произвольный атрибут (передаётся по цепочке middleware)
$ctx -> set ( 'my-module.checked' , true );
// Прервать выполнение
$ctx -> abort ( 'reason' , 403 );
if ( $ctx -> isAborted ()) {
return $ctx ;
}
```
---
## Navbar
### Добавление пунктов меню
Метод `registerNavbar()` вызывается один раз при boot. Используйте `NavbarRegistry::add()` :
2026-05-06 18:01:07 +03:00
```php
public function registerNavbar () : void {
2026-06-14 14:25:19 +03:00
// Пункт в Service Setup
2026-05-06 18:01:07 +03:00
NavbarRegistry :: add (( new NavbarItem ( 'management.service_setup.my_module' ))
-> parent ( 'management.service_setup' )
-> url ( 'my_module' )
-> label ( 'my_module' )
-> permissions ([ 'my_module' ])
-> order ( 60 ));
2026-06-14 14:25:19 +03:00
// Пункт в Logs (megamenu)
2026-05-06 18:01:07 +03:00
NavbarRegistry :: add (( new NavbarItem ( 'management.logs.my_module_log' ))
-> parent ( 'management.logs' )
-> url ( 'my_module_logs' )
-> label ( '' , 'My Module Logs' )
-> permissions ([ 'my_module' ])
-> order ( 170 ));
}
```
2026-06-14 14:25:19 +03:00
### Зарезервированные слоты для модулей
| Родительский узел | Слоты для модулей |
| ------------------- | ------------------ |
| `management.service_setup` | `order` ≥ 60 |
| `management.logs` | `order` ≥ 170 |
| Прочие секции | Не зарезервировано, уточняйте с core |
### Правила
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
1. `key` — уникальный, стабильный, формат `section.group.item`
2. `parent` — должен ссылаться на существующий узел core-дерева
3. `order` — позиция внутри одного parent (меньше = выше в списке)
4. `label('key')` — переводимый текст, `label('', 'Literal')` — фиксированный
5. `permissions(['perm'])` — видимость по разрешению (OR-логика)
6. Если нет пунктов меню — оставьте `registerNavbar()` пустым
2026-05-06 18:01:07 +03:00
---
2026-06-14 14:25:19 +03:00
## Отключение и включение модулей
Добавьте в `src/config/modules.php` :
```php
return [
2026-06-15 18:27:23 +03:00
'my-module' => [ 'state' => 'disabled' ], // предпочтительно
// или legacy-форма (обратная совместимость):
2026-06-14 14:25:19 +03:00
'my-module' => [ 'enabled' => false ],
];
```
2026-06-15 18:27:23 +03:00
Допустимые значения `state` (enum `ModuleState` ):
| Значение | Смысл |
| -------- | ----- |
| `enabled` | Модуль загружается и стартует (по умолчанию) |
| `disabled` | Обнаружен, но пропускается |
| `installing` | Переходное состояние при установке |
2026-06-30 22:45:15 +03:00
| `failed` | Установка завершилась ошибкой; пропускается (не загружается) |
2026-06-15 18:27:23 +03:00
2026-06-14 14:25:19 +03:00
Файл содержит только overrides. Если пустой или отсутствует — все найденные модули загружаются.
2026-05-06 18:01:07 +03:00
2026-06-30 22:45:15 +03:00
> **Диагностика в панели.** На странице **Modules** рядом со статусом модуля показывается жёлтый бейдж **⚠ Dependency issue**, если у модуля есть обязательная зависимость, которая отсутствует или не включена (например, `plex` числится `Enabled`, но `watch` в состоянии `failed`). В подсказке бейджа перечислены конкретные проблемы. Это поле (`dependency_warnings`) вычисляет `ModuleManager::listModules()`.
2026-06-14 14:25:19 +03:00
Можно также переопределить класс модуля:
2026-05-06 18:01:07 +03:00
2026-06-14 14:25:19 +03:00
```php
return [
2026-06-15 18:27:23 +03:00
'my-module' => [ 'class' => 'XcVm\\Module\\MyModuleCustom\\MyModuleCustomModule' ],
2026-06-14 14:25:19 +03:00
];
```
2026-05-06 18:01:07 +03:00
---
2026-06-14 14:25:19 +03:00
## Как работает загрузка
```
ModuleLoader::loadAll()
│
2026-06-26 15:56:15 +03:00
├── glob('Modules/*/module.json')
2026-06-14 14:25:19 +03:00
├── читает overrides из config/modules.php
├── фильтрует по environment (main/lb/any)
├── readManifest() → normalizes: dependencies, optional_dependencies, priority
│
├── resolveLoadOrder() — топологическая сортировка DFS
2026-06-30 22:45:15 +03:00
│ ├── pruneUnsatisfiableModules() → модули с недоступной обязательной
│ │ зависимостью отбрасываются (каскадно, с предупреждением в лог)
2026-06-14 14:25:19 +03:00
│ ├── optional deps → пропускается если отсутствует
│ └── при равной позиции: sort по priority desc, затем alphabetically
│
└── для каждого модуля в порядке:
├── registerModuleAutoloader($path)
├── resolveClassName('my-module') → 'MyModule'
└── new MyModule()
ModuleLoader::bootAll($container, $router, $pipeline)
├── (new CoreNavbarProvider())->registerNavbar() ← core navbar первым
│
└── для каждого модуля:
├── instanceof ServiceProviderInterface → boot($container)
│ → registerEventSubscribers()
├── instanceof StreamMiddlewareProviderInterface → pipeline->pipe(middleware)
├── instanceof RouteProviderInterface → registerRoutes($router)
└── instanceof NavbarProviderInterface → registerNavbar()
```
2026-03-18 20:33:25 +03:00
2026-06-15 18:27:23 +03:00
Соглашение по имени класса: `my-module` → FQN `XcVm\Module\MyModule\MyModuleModule`
(kebab-case → PascalCase; можно переопределить через ключ `class` в конфиге).
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
Переопределить класс можно через `config/modules.php` :
2026-03-18 20:33:25 +03:00
```php
2026-06-14 14:25:19 +03:00
return [
'my-module' => [ 'class' => 'MyModuleV2' ],
];
```
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
---
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
## Marketplace: установка через C-расширение
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
Модули из платформы устанавливаются через `ModuleManager::downloadFromPlatform()` :
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
```php
$manager -> downloadFromPlatform ( slug : 'my-module' , version : '1.2.0' , apiKey : $key );
2026-03-18 20:33:25 +03:00
```
2026-06-14 14:25:19 +03:00
Под капотом:
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
1. `XC_VM::module_install($slug, $version, $apiKey)` — C-расширение скачивает, дешифрует и распаковывает модуль
2. `installModule($slug)` — запускает `install()` у модуля
3. `EventDispatcher::dispatch(new PackageInstalledEvent(...))` — диспетчеризует событие
4. `hotReload($slug, $path)` — загружает и бутит модуль в текущем запросе **без рестарта PHP-FPM**
2026-03-18 20:33:25 +03:00
2026-06-14 14:25:19 +03:00
---
2026-03-18 20:33:25 +03:00
2026-08-11 19:22:09 +03:00
## Изолированные подсистемы
2026-03-18 20:33:25 +03:00
2026-08-11 19:22:09 +03:00
Модуль может быть полностью изолированной подсистемой с собственной точкой входа и bootstrap (как Ministra). Это **соглашение** , а не маркер-интерфейс — он остаётся обычным модулем на `BaseModule` :
2026-06-14 14:25:19 +03:00
```php
2026-08-11 19:22:09 +03:00
class MyModule extends BaseModule {
2026-06-15 18:05:27 +03:00
public function getName () : string { return 'my-module' ; }
public function getVersion () : string { return '1.0.0' ; }
2026-06-14 14:25:19 +03:00
}
```
2026-03-18 20:33:25 +03:00
2026-08-11 19:22:09 +03:00
Изоляция означает, что подсистема запускается через собственную публичную точку входа (например, `my-module/portal.php` , путь относительно `src/` с отдельным bootstrap). Она делит инфраструктуру (БД, кэш, конфиг), но **не** участвует в основном `Router` , `ModuleLoader::bootAll()` и `NavbarRegistry` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
---
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
## Контроллер (опционально)
2026-03-16 22:34:17 +03:00
```php
2026-06-14 14:25:19 +03:00
class MyController {
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
private string $viewsPath ;
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
public function __construct () {
$this -> viewsPath = __DIR__ . '/views' ;
2026-06-26 15:56:15 +03:00
require_once MAIN_HOME . 'Public/Views/layouts/admin.php' ;
require_once MAIN_HOME . 'Public/Views/layouts/footer.php' ;
2026-03-16 22:34:17 +03:00
}
2026-06-14 14:25:19 +03:00
public function index () : void {
$_TITLE = 'My Module' ;
renderUnifiedLayoutHeader ( 'admin' , [ '_TITLE' => $_TITLE ]);
include $this -> viewsPath . '/my_page.php' ;
renderUnifiedLayoutFooter ( 'admin' );
include $this -> viewsPath . '/my_page_scripts.php' ;
}
public function apiAction () : void {
echo json_encode ([ 'result' => true ]);
exit ;
2026-03-16 22:34:17 +03:00
}
}
```
2026-06-14 14:25:19 +03:00
| Правило | |
| --------- | -- |
| `__DIR__ . '/views'` | viewsPath — контроллер внутри директории модуля |
| GET-страницы | `renderUnifiedLayoutHeader` до view, `renderUnifiedLayoutFooter` после |
| API-экшены | Без layout — JSON напрямую |
---
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
## Крон-задача (опционально)
2026-03-16 22:34:17 +03:00
```php
2026-06-26 15:56:15 +03:00
// src/Modules/my-module/MyCronJob.php
2026-03-16 22:34:17 +03:00
class MyCronJob implements CommandInterface {
use CronTrait ;
2026-06-14 14:25:19 +03:00
public function getName () : string { return 'cron:my_task' ; }
public function getDescription () : string { return 'My module background task' ; }
2026-03-16 22:34:17 +03:00
public function execute ( array $rArgs ) : int {
2026-06-14 14:25:19 +03:00
if ( ! $this -> assertRunAsXcVm ()) { return 1 ; }
2026-03-16 22:34:17 +03:00
require INCLUDES_PATH . 'admin.php' ;
require_once __DIR__ . '/MyCron.php' ;
$this -> initCron ( 'XC_VM[MyTask]' );
MyCron :: run ();
return 0 ;
}
}
```
2026-06-14 14:25:19 +03:00
Регистрация в модуле:
2026-03-16 22:34:17 +03:00
```php
public function registerCommands ( CommandRegistry $registry ) : void {
$registry -> register ( new MyCronJob ());
}
```
2026-06-15 18:27:23 +03:00
Объявить расписание через `getCronEntries()` в классе модуля:
2026-03-16 22:34:17 +03:00
```php
2026-06-15 18:27:23 +03:00
public function getCronEntries () : array {
return [
'*/5 * * * *' => 'cron:my_task' ,
];
}
2026-03-16 22:34:17 +03:00
```
2026-06-15 18:27:23 +03:00
`ModuleLoader::collectCronEntries()` агрегирует записи всех модулей, `StartupCommand` /
`StatusCommand` автоматически записывают их в системный crontab — изменять файлы ядра не нужно.
**Формат:** ключ = cron-выражение, значение = имя команды из `registerCommands()` .
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## PSR-11: ContainerInterface
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
`ServiceContainer` реализует `ContainerInterface` :
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
```php
public function get ( string $id ) : mixed ; // throws NotFoundException если не найден
public function has ( string $id ) : bool ;
2026-03-16 22:34:17 +03:00
```
2026-06-14 14:25:19 +03:00
`NotFoundException` реализует `NotFoundExceptionInterface` → `ContainerExceptionInterface` .
2026-03-18 20:33:25 +03:00
2026-06-26 15:56:15 +03:00
Интерфейсы находятся в `src/Core/Container/Psr/` . Composer не используется — файлы включены в проект напрямую.
2026-03-16 22:34:17 +03:00
---
## Чеклист добавления модуля
2026-06-26 15:56:15 +03:00
- [ ] `mkdir -p src/Modules/<name>/`
2026-06-14 14:25:19 +03:00
- [ ] Создать `module.json` (name, version, requires_core, priority, optional_dependencies)
2026-08-07 20:49:36 +03:00
- [ ] Проставить постоянный `hash_id` (`php -r 'echo bin2hex(random_bytes(16));'` ; руками не писать)
2026-06-15 18:05:27 +03:00
- [ ] Создать `<Name>Module.php` (extends `BaseModule` )
2026-07-02 21:21:19 +03:00
- [ ] Задать версию в **обоих** местах — `"version"` в `module.json` и `getVersion()` — они должны совпадать (повышать обе перед публикацией)
2026-06-14 14:25:19 +03:00
- [ ] `boot()` — зарегистрировать сервисы через `$container->set()`
- [ ] `getEventSubscribers()` — подписки на типизированные события
- [ ] `registerRoutes()` — маршруты (или пустой метод)
- [ ] `registerNavbar()` — пункты меню (или пустой метод)
- [ ] `registerCommands()` — крон-задачи (или пустой метод)
- [ ] (опц.) `implements StreamMiddlewareProviderInterface` + `getStreamMiddleware()`
- [ ] (опц.) Контроллер + views/
- [ ] (опц.) CronJob + регистрация в StartupCommand
2026-07-02 21:21:19 +03:00
- [ ] (если своя схема) `database.sql` (мастер), `database_drop.sql` (удаление), `migrations/<semver>.sql` (дельты)
- [ ] (если миграции с PHP-логикой) `implements MigratableInterface` + `getMigrations()`
2026-06-26 15:56:15 +03:00
- [ ] Проверить: `php -l src/Modules/<name>/<Name>Module.php`
2026-06-14 14:25:19 +03:00
- [ ] Проверить: `php console.php --list` показывает команды модуля
- [ ] Проверить: удаление директории не вызывает ошибок ядра
2026-03-16 22:34:17 +03:00
---
2026-06-14 14:25:19 +03:00
## FAQ
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Как отключить модуль?**
2026-06-15 18:27:23 +03:00
A: `src/config/modules.php` → `'my-module' => ['state' => 'disabled']` .
Legacy-форма `'enabled' => false` тоже принимается для обратной совместимости.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Нужна ли регистрация в конфиге для загрузки?**
2026-06-26 15:56:15 +03:00
A: Нет. `ModuleLoader` сам находит все модули по `Modules/*/module.json` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Мой модуль зависит от другого. Как объявить?**
2026-06-26 15:56:15 +03:00
A: В `module.json` через `dependencies` (обязательно) или `optional_dependencies` (мягко). Предпочитайте выносить общую логику в `Core/` вместо межмодульных зависимостей.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Как задекорировать сервис другого модуля?**
A: `$container->decorate('service.id', MyDecorator::class, priority: 10)` в своём `boot()` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Как подписаться на событие с приоритетом?**
A: `[EventClass::class => [[MyHandler::class, 'method'], 50]]` в `getEventSubscribers()` . Можно также вызвать `EventDispatcher::listen()` напрямую.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Как модуль получает $db?**
A: `$db = $container->get('db')` в `boot()` . Прямой `global $db` — устарело.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Как модуль получает настройки?**
A: `$settings = $container->get('settings')` или `SettingsManager::getAll()['key']` .
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Почему мой middleware не вызывается?**
A: Проверьте, что модуль реализует `StreamMiddlewareProviderInterface` (не `ModuleInterface` — это разные контракты). `bootAll()` должен быть вызван с `$pipeline` аргументом.
2026-03-16 22:34:17 +03:00
2026-06-14 14:25:19 +03:00
**Q: Мой модуль MAIN-only — что делать?**
A: Ничего. Все модули MAIN-only по умолчанию — `modules/` не входит в `LB_DIRS` . Для LB используйте `"environment": "lb"` или `"any"` .
2026-06-26 15:56:15 +03:00
## Связанные файлы
| Файл | Роль |
| --- | --- |
| `src/Core/Module/ModuleLoader.php` | Обнаружение, сортировка и загрузка модулей; PSR-4-резолвер |
| `src/config/modules.php` | Конфиг включения / переопределения класса модуля |
| `src/Modules/` | Каталоги модулей |
| `src/Core/Module/Contract/` | Под-интерфейсы модулей |