Files
XC_VM/docs/ru/development/modules.md
T
Divarion-D f4555e7943 docs: reorganize structure, sync with current code, add missing framework reference
Structure:
- Split development/ (20 files) into development/ (framework reference) and guides/ (how-to)
- Moved 13 how-to files into new guides/ category (auth, cli, error-handling, etc.)
- Moved navbar-rendering.md from system/ into development/
- Deleted empty system/README.md files
- Deleted documentation_gaps.md (all gaps resolved) and redundant info/update.md

Sidebar / navbar:
- Removed all emojis from _sidebar.md (EN + RU) — fixes tree hierarchy rendering
- Removed flag emojis from _navbar.md language switcher
- Removed duplicate entries from the old "System Documentation" section
- Renamed section headers: "Development" -> "Framework Reference" + "Developer Guides"

Content fixes (modules.md EN + RU):
- Updated "Class naming" section: now documents XcVm\Module\{Pascal} namespaces
  (was "global PHP namespace — Until PHP namespaces are introduced")
- Updated main module class example to use namespace declaration + use statements
- Updated class naming in ModuleLoader description to FQN
- Replaced installCrontab() manual crontab instruction with getCronEntries() API
- Updated enable/disable section: added ModuleState enum table, updated config examples
- Fixed FAQ answer for disabling a module

Content fixes (bootstrap-contexts.md EN + RU):
- Replaced CONTEXT_* string constants with BootContext enum cases throughout
- Updated boot() signature: string $context -> BootContext $context
- Updated getContext() return type: ?string -> ?BootContext

New framework reference docs (EN + RU):
- docs/{en,ru}/development/event-system.md: EventDispatcher singleton bridge,
  #[ListensTo] attribute, getEventSubscribers() array API, stoppable events,
  built-in event catalog, custom event authoring, ListensTo attribute reference
- docs/{en,ru}/development/exception-hierarchy.md: full XcVmException tree,
  container vs module subtrees, PSR-11 compliance notes, catch-by-subsystem examples
2026-06-15 18:27:23 +03:00

756 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Система модулей
## Обзор
Модуль — изолированная директория в `src/modules/` с известным контрактом. Удаление модуля **не ломает систему** — она продолжает работать, деградируя в функциональности.
Система построена на принципах **Extensible Platform**:
- Ядро (`core/`) ничего не знает о модулях
- Модули расширяют ядро через интерфейсы-контракты
- Никакой правки файлов ядра, никакого eval, никакого monkey patching
- Любой модуль отключается через `config/modules.php` без последствий для ядра
---
## Структура директории модуля
```
modules/
└── my-module/
├── module.json # Метаданные + поля загрузки
├── MyModule.php # Главный класс (implements ModuleInterface)
├── MyService.php # Сервисы модуля
├── MyController.php # Контроллер (если есть страницы)
├── MyCron.php # Крон-логика
├── MyCronJob.php # CLI-обёртка (implements CommandInterface)
├── MyStreamMiddleware.php # Stream-middleware (опционально)
├── views/
│ ├── my_page.php
│ └── my_page_scripts.php
└── migrations/
└── 001_create_table.sql
```
---
## Манифест `module.json`
```json
{
"name": "my-module",
"description": "Краткое описание модуля",
"version": "1.0.0",
"requires_core": ">=2.0",
"environment": "main",
"dependencies": [],
"optional_dependencies": [],
"has_navbar": false,
"has_settings": false,
"priority": 0
}
```
### Поля манифеста
| Поле | Тип | По умолчанию | Описание |
| ------ | ----- | :---: | ------------ |
| `name` | `string` | — | Уникальное имя (совпадает с именем директории, kebab-case) |
| `description` | `string` | `""` | Краткое человекочитаемое описание |
| `version` | `string` | — | Semver-версия (`1.0.0`) |
| `requires_core` | `string` | — | Минимальная версия ядра (`>=2.0`) |
| `environment` | `string` | `"main"` | `main` — основной сервер, `lb` — load-balancer, `any` — оба |
| `dependencies` | `array` | `[]` | Обязательные зависимости: при отсутствии — ошибка загрузки |
| `optional_dependencies` | `array` | `[]` | Мягкие зависимости: при отсутствии модуль загружается без них |
| `has_navbar` | `bool` | `false` | Есть ли пункты навбара |
| `has_settings` | `bool` | `false` | Есть ли страница настроек |
| `priority` | `int` | `0` | Приоритет загрузки: выше значение — раньше загрузится (при топологически равном положении) |
### Разница между `dependencies` и `optional_dependencies`
```json
{
"dependencies": ["tmdb"],
"optional_dependencies": ["plex"]
}
```
- `dependencies`: модуль `tmdb` **обязан** быть загружен до `my-module`. Если `tmdb` отсутствует — `loadAll()` выбросит `RuntimeException`.
- `optional_dependencies`: если `plex` присутствует — он загрузится **до** `my-module`. Если отсутствует — загрузка продолжается без него.
### Приоритет загрузки
При топологически равных позициях (нет зависимости друг от друга), модули с бо́льшим `priority` загружаются и бутятся первыми.
```json
{ "name": "auth-guard", "priority": 100 } ← загрузится первым
{ "name": "tmdb", "priority": 50 } ← второй
{ "name": "watch", "priority": 0 } ← третий (по умолчанию)
```
При равных `priority` — алфавитный порядок (детерминированность).
---
## Интерфейсы модуля
`ModuleInterface` — составной интерфейс, объединяющий 4 суб-интерфейса:
```
ModuleInterface
├── ServiceProviderInterface boot(), getEventSubscribers()
├── RouteProviderInterface registerRoutes()
├── CommandProviderInterface registerCommands()
└── NavbarProviderInterface registerNavbar()
+ getName(), getVersion(), install(), uninstall()
```
Пятый суб-интерфейс — **опциональный**, не входит в `ModuleInterface`:
```
StreamMiddlewareProviderInterface getStreamMiddleware()
```
Модуль реализует его дополнительно, если хочет участвовать в стрим-pipeline.
---
## Класс модуля
Файл `src/modules/my-module/MyModule.php`.
Расширяйте `BaseModule` — он предоставляет пустые реализации по умолчанию для всех
необязательных методов. Обязательны только `getName()` и `getVersion()`.
```php
<?php
namespace XcVm\Module\MyModule;
use BaseModule;
use ServiceContainer;
use Router;
use CommandRegistry;
class MyModuleModule extends BaseModule {
public function getName(): string {
return 'my-module';
}
public function getVersion(): string {
return '1.0.0';
}
public function boot(ServiceContainer $container): void {
$container->set('my-module.service', function (ServiceContainer $c) {
return new MyModuleService($c->get('db'));
});
}
public function getEventSubscribers(): array {
return [
StreamStartedEvent::class => [MyModuleHandler::class, 'onStreamStarted'],
// С приоритетом: [callable, int]
UserAuthenticatedEvent::class => [[MyModuleHandler::class, 'onAuth'], 20],
];
}
public function registerRoutes(Router $router): void {
$router->get('my-module', [MyModuleController::class, 'index'], [
'permission' => ['adv', 'my_module'],
]);
$router->api('my_action', [MyModuleController::class, 'apiAction'], [
'permission' => ['adv', 'my_module'],
]);
}
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyModuleCronJob());
}
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));
}
// override install()/uninstall() only if migrations or cleanup are needed
}
```
### Контракт методов
| Метод | Интерфейс | Описание |
| ------- | ----------- | ---------- |
| `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 |
---
## PHP-пространства имён
Каждый модуль живёт в своём PHP-пространстве имён: `XcVm\Module\{Pascal}`, где `{Pascal}` —
PascalCase-вариант имени директории модуля.
```
src/modules/my-module/ → namespace XcVm\Module\MyModule;
src/modules/watch/ → namespace XcVm\Module\Watch;
```
Главный файл модуля обязан объявлять это пространство имён и расширять `BaseModule`:
```php
<?php
namespace XcVm\Module\MyModule;
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;
```
**Правила:**
- Имя файла главного класса: `<PascalName>Module.php` — обязательно (соглашение ModuleLoader)
- Имена остальных файлов: `<PascalName><Purpose>.php`
- Добавляйте `use` для каждого класса ядра, на который есть ссылка
- Никогда не импортируйте классы из других модулей — общайтесь через события или DI-контейнер
---
## 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);
}
```
**Защищённые сервисы** — декорировать нельзя: `db`, `settings`, `config`, `auth`.
Попытка задекорировать защищённый сервис выбросит `RuntimeException`.
**Порядок применения декораторов:** наибольший `priority` = самый внешний слой (вызывается первым).
---
## PSR-14 События
Система событий — типизированные классы, а не строки.
### Подписка на события
В `getEventSubscribers()` возвращайте карту `EventClass::class → callable`:
```php
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(),
));
```
### Прерываемые события (StoppableEventInterface)
Если слушатель вызвал `$event->stopPropagation()`, остальные слушатели **не вызываются**.
```php
EventDispatcher::listen(StreamStartingEvent::class, function (StreamStartingEvent $e): void {
if ($this->isBlocked($e)) {
$e->abort('blocked by my-module'); // специфичен для StreamStartingEvent
$e->stopPropagation();
}
});
```
### Встроенные события ядра
| Класс события | Когда диспетчеризуется | Прерываемое |
| --------------- | ---------------------- | :-----------: |
| `ModuleLoadedEvent` | После успешной загрузки файла модуля | ❌ |
| `ModuleBootedEvent` | После вызова `boot()` у модуля | ❌ |
| `PackageInstalledEvent` | После установки через Marketplace | ❌ |
| `UserAuthenticatedEvent` | Успешная аутентификация | ❌ |
| `UserLoggedOutEvent` | Выход пользователя | ❌ |
| `StreamStartingEvent` | Перед запуском стрима | ✅ |
| `StreamStartedEvent` | Стрим успешно запущен | ❌ |
| `StreamStoppedEvent` | Стрим остановлен | ❌ |
| `SettingsChangedEvent` | Изменение настроек панели | ❌ |
Все типизированные события находятся в `src/core/Events/`.
---
## Stream Middleware (опционально)
Если модуль хочет участвовать в обработке стрим-запросов, он реализует `StreamMiddlewareProviderInterface` (не входит в `ModuleInterface`):
```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));
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()`:
```php
public function registerNavbar(): void {
// Пункт в Service Setup
NavbarRegistry::add((new NavbarItem('management.service_setup.my_module'))
->parent('management.service_setup')
->url('my_module')
->label('my_module')
->permissions(['my_module'])
->order(60));
// Пункт в Logs (megamenu)
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));
}
```
### Зарезервированные слоты для модулей
| Родительский узел | Слоты для модулей |
| ------------------- | ------------------ |
| `management.service_setup` | `order` ≥ 60 |
| `management.logs` | `order` ≥ 170 |
| Прочие секции | Не зарезервировано, уточняйте с core |
### Правила
1. `key` — уникальный, стабильный, формат `section.group.item`
2. `parent` — должен ссылаться на существующий узел core-дерева
3. `order` — позиция внутри одного parent (меньше = выше в списке)
4. `label('key')` — переводимый текст, `label('', 'Literal')` — фиксированный
5. `permissions(['perm'])` — видимость по разрешению (OR-логика)
6. Если нет пунктов меню — оставьте `registerNavbar()` пустым
---
## Отключение и включение модулей
Добавьте в `src/config/modules.php`:
```php
return [
'my-module' => ['state' => 'disabled'], // предпочтительно
// или legacy-форма (обратная совместимость):
'my-module' => ['enabled' => false],
];
```
Допустимые значения `state` (enum `ModuleState`):
| Значение | Смысл |
| -------- | ----- |
| `enabled` | Модуль загружается и стартует (по умолчанию) |
| `disabled` | Обнаружен, но пропускается |
| `installing` | Переходное состояние при установке |
| `failed` | Установка завершилась ошибкой; пропускается |
Файл содержит только overrides. Если пустой или отсутствует — все найденные модули загружаются.
Можно также переопределить класс модуля:
```php
return [
'my-module' => ['class' => 'XcVm\\Module\\MyModuleCustom\\MyModuleCustomModule'],
];
```
---
## Как работает загрузка
```
ModuleLoader::loadAll()
│
├── glob('modules/*/module.json')
├── читает overrides из config/modules.php
├── фильтрует по environment (main/lb/any)
├── readManifest() → normalizes: dependencies, optional_dependencies, priority
│
├── resolveLoadOrder() — топологическая сортировка DFS
│ ├── обязательные deps → RuntimeException если отсутствует
│ ├── 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()
```
Соглашение по имени класса: `my-module` → FQN `XcVm\Module\MyModule\MyModuleModule`
(kebab-case → PascalCase; можно переопределить через ключ `class` в конфиге).
Переопределить класс можно через `config/modules.php`:
```php
return [
'my-module' => ['class' => 'MyModuleV2'],
];
```
---
## Marketplace: установка через C-расширение
Модули из платформы устанавливаются через `ModuleManager::downloadFromPlatform()`:
```php
$manager->downloadFromPlatform(slug: 'my-module', version: '1.2.0', apiKey: $key);
```
Под капотом:
1. `XC_VM::module_install($slug, $version, $apiKey)` — C-расширение скачивает, дешифрует и распаковывает модуль
2. `installModule($slug)` — запускает `install()` у модуля
3. `EventDispatcher::dispatch(new PackageInstalledEvent(...))` — диспетчеризует событие
4. `hotReload($slug, $path)` — загружает и бутит модуль в текущем запросе **без рестарта PHP-FPM**
---
## Изолированные подсистемы (BoundaryInterface)
Если модуль является изолированной подсистемой с собственным bootstrap (как Ministra), он реализует `BoundaryInterface`:
```php
class MyModule extends BaseModule implements BoundaryInterface {
public function getName(): string { return 'my-module'; }
public function getVersion(): string { return '1.0.0'; }
public function getEntryPoint(): string {
return 'my-module/portal.php';
}
public function isIsolated(): bool {
return true;
}
}
```
`BoundaryInterface` — маркер изоляции. `isIsolated() = true` означает, что подсистема запускается через собственный entry point с отдельным bootstrap.
---
## Контроллер (опционально)
```php
class MyController {
private string $viewsPath;
public function __construct() {
$this->viewsPath = __DIR__ . '/views';
require_once MAIN_HOME . 'public/Views/layouts/admin.php';
require_once MAIN_HOME . 'public/Views/layouts/footer.php';
}
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;
}
}
```
| Правило | |
| --------- | -- |
| `__DIR__ . '/views'` | viewsPath — контроллер внутри директории модуля |
| GET-страницы | `renderUnifiedLayoutHeader` до view, `renderUnifiedLayoutFooter` после |
| API-экшены | Без layout — JSON напрямую |
---
## Крон-задача (опционально)
```php
// src/modules/my-module/MyCronJob.php
class MyCronJob implements CommandInterface {
use CronTrait;
public function getName(): string { return 'cron:my_task'; }
public function getDescription(): string { return 'My module background task'; }
public function execute(array $rArgs): int {
if (!$this->assertRunAsXcVm()) { return 1; }
require INCLUDES_PATH . 'admin.php';
require_once __DIR__ . '/MyCron.php';
$this->initCron('XC_VM[MyTask]');
MyCron::run();
return 0;
}
}
```
Регистрация в модуле:
```php
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
```
Объявить расписание через `getCronEntries()` в классе модуля:
```php
public function getCronEntries(): array {
return [
'*/5 * * * *' => 'cron:my_task',
];
}
```
`ModuleLoader::collectCronEntries()` агрегирует записи всех модулей, `StartupCommand` /
`StatusCommand` автоматически записывают их в системный crontab — изменять файлы ядра не нужно.
**Формат:** ключ = cron-выражение, значение = имя команды из `registerCommands()`.
---
## PSR-11: ContainerInterface
`ServiceContainer` реализует `ContainerInterface`:
```php
public function get(string $id): mixed; // throws NotFoundException если не найден
public function has(string $id): bool;
```
`NotFoundException` реализует `NotFoundExceptionInterface` → `ContainerExceptionInterface`.
Интерфейсы находятся в `src/core/Container/Psr/`. Composer не используется — файлы включены в проект напрямую.
---
## Чеклист добавления модуля
- [ ] `mkdir -p src/modules/<name>/`
- [ ] Создать `module.json` (name, version, requires_core, priority, optional_dependencies)
- [ ] Создать `<Name>Module.php` (extends `BaseModule`)
- [ ] `boot()` — зарегистрировать сервисы через `$container->set()`
- [ ] `getEventSubscribers()` — подписки на типизированные события
- [ ] `registerRoutes()` — маршруты (или пустой метод)
- [ ] `registerNavbar()` — пункты меню (или пустой метод)
- [ ] `registerCommands()` — крон-задачи (или пустой метод)
- [ ] (опц.) `implements StreamMiddlewareProviderInterface` + `getStreamMiddleware()`
- [ ] (опц.) Контроллер + views/
- [ ] (опц.) CronJob + регистрация в StartupCommand
- [ ] Проверить: `php -l src/modules/<name>/<Name>Module.php`
- [ ] Проверить: `php console.php --list` показывает команды модуля
- [ ] Проверить: удаление директории не вызывает ошибок ядра
---
## FAQ
**Q: Как отключить модуль?**
A: `src/config/modules.php` → `'my-module' => ['state' => 'disabled']`.
Legacy-форма `'enabled' => false` тоже принимается для обратной совместимости.
**Q: Нужна ли регистрация в конфиге для загрузки?**
A: Нет. `ModuleLoader` сам находит все модули по `modules/*/module.json`.
**Q: Мой модуль зависит от другого. Как объявить?**
A: В `module.json` через `dependencies` (обязательно) или `optional_dependencies` (мягко). Предпочитайте выносить общую логику в `core/` вместо межмодульных зависимостей.
**Q: Как задекорировать сервис другого модуля?**
A: `$container->decorate('service.id', MyDecorator::class, priority: 10)` в своём `boot()`.
**Q: Как подписаться на событие с приоритетом?**
A: `[EventClass::class => [[MyHandler::class, 'method'], 50]]` в `getEventSubscribers()`. Можно также вызвать `EventDispatcher::listen()` напрямую.
**Q: Как модуль получает $db?**
A: `$db = $container->get('db')` в `boot()`. Прямой `global $db` — устарело.
**Q: Как модуль получает настройки?**
A: `$settings = $container->get('settings')` или `SettingsManager::getAll()['key']`.
**Q: Почему мой middleware не вызывается?**
A: Проверьте, что модуль реализует `StreamMiddlewareProviderInterface` (не `ModuleInterface` — это разные контракты). `bootAll()` должен быть вызван с `$pipeline` аргументом.
**Q: Мой модуль MAIN-only — что делать?**
A: Ничего. Все модули MAIN-only по умолчанию — `modules/` не входит в `LB_DIRS`. Для LB используйте `"environment": "lb"` или `"any"`.