Files
XC_VM/docs/en/development/modules.md
T
Divarion-D 9211817695 docs: add controller creation guide to module documentation
- Add Step 4a with controller pattern using renderUnifiedLayoutHeader/Footer
- Add layout rules table and important notes
- Add checklist items for modules with admin pages
- Add bootAll() limitation warning
- Both EN and RU versions
2026-03-18 20:33:25 +03:00

417 lines
13 KiB
Markdown

# Module System
## Overview
A module is an isolated directory under `src/modules/` with a known contract. Removing a module **does not break the system** — it continues working with degraded functionality.
### Architecture
```
modules/
├── my-module/
│ ├── module.json # Metadata (name, description, version, requires_core)
│ ├── MyModule.php # Source of truth (implements ModuleInterface)
│ ├── MyService.php # Module services
│ ├── MyController.php # Controller (if pages exist)
│ ├── MyCron.php # Cron logic (if any)
│ ├── MyCronJob.php # CLI cron wrapper (implements CommandInterface)
│ ├── views/ # Page templates
│ │ ├── my_page.php
│ │ └── my_page_scripts.php
│ └── migrations/ # SQL migrations (if any)
│ └── 001_create_table.sql
```
### Principles
| Rule | Description |
|------|-------------|
| **PHP is the source of truth** | All behavior is defined in the module class, not in JSON |
| **module.json is metadata only** | `name`, `description`, `version`, `requires_core` |
| **Auto-discovery** | `ModuleLoader` scans `modules/*/module.json` — no config registration needed |
| **Isolation** | Module depends on `core/` and `domain/`, but NEVER on other modules |
| **Graceful degradation** | Removing the module directory causes no errors |
| **No reverse dependencies** | Core (`core/`) is unaware of modules |
| **DI via container** | Services registered in `boot()`, not via globals |
| **Explicit command registration** | Module registers commands in `registerCommands()`, no filesystem scanning |
---
## Step 1. Create a directory
```bash
mkdir -p src/modules/my-module
```
Directory name = module name. Use kebab-case: `my-module`, `theft-detection`.
---
## Step 2. Create the manifest `module.json`
```json
{
"name": "my-module",
"version": "1.0.0",
"requires_core": ">=2.0"
}
```
### Manifest fields
| Field | Type | Required | Description |
|-------|------|:---:|-------------|
| `name` | `string` | ✅ | Unique module name (matches directory name) |
| `description` | `string` | ⛔ | Short human-readable module description |
| `version` | `string` | ✅ | Semver version (`1.0.0`) |
| `requires_core` | `string` | ✅ | Minimum core version (`>=2.0`) |
> **Important:** `module.json` contains only metadata. Crons, commands, routes, events, pages — everything is defined in the module's PHP class.
---
## Step 3. Create the module class
File `src/modules/my-module/MyModule.php`:
```php
<?php
class MyModule implements ModuleInterface {
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', 'MyService');
}
public function registerRoutes(Router $router): void {
$router->get('my-module', [MyController::class, 'index'], [
'permission' => ['adv', 'my_module'],
]);
$router->api('my_action', [MyController::class, 'apiAction'], [
'permission' => ['adv', 'my_module'],
]);
}
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
public function getEventSubscribers(): array {
return [];
}
public function install(): void {
// Create tables, seed data, etc.
}
public function uninstall(): void {
// Clean up module data
}
}
```
### `ModuleInterface` contract
| Method | Description |
|--------|-------------|
| `getName(): string` | Unique name (matches directory) |
| `getVersion(): string` | Semver version |
| `boot(ServiceContainer)` | Register services. Called once on load |
| `registerRoutes(Router)` | HTTP routes and API actions |
| `registerCommands(CommandRegistry)` | Explicit registration of CLI commands and cron tasks |
| `getEventSubscribers(): array` | Core event subscriptions |
| `install(): void` | Module installation (migrations, seed data) |
| `uninstall(): void` | Module data cleanup |
---
## Step 4. Automatic registration
**No config registration needed.** `ModuleLoader` automatically discovers all modules from `modules/*/module.json`.
To **disable** a module — add to `src/config/modules.php`:
```php
return [
'my-module' => ['enabled' => false],
];
```
`config/modules.php` contains only overrides. If the file is empty or missing — all discovered modules are loaded.
### How loading works
1. `ModuleLoader::loadAll()` scans `modules/*/module.json`
2. Checks overrides in `config/modules.php`
3. Resolves class by convention: `my-module` → `MyModule` (kebab-case → PascalCase + Module)
4. Creates module instance
In web context (bootstrap.php):
- `bootAll($container, $router)` → calls `boot()`, `registerRoutes()`, `getEventSubscribers()`
> ⚠️ **Current limitation:** `ModuleLoader::bootAll()` is not yet invoked in the web front controller. Module routes are currently registered **statically** in `public/routes/admin.php`. This will be addressed in a future update. See `specs/MODULE_SYSTEM_SPEC.md` §0.3 for details.
In CLI context (console.php):
- `registerAllCommands($registry)` → calls `registerCommands()` on each module
---
## Step 4a. Create a controller (optional)
If the module has admin pages, create a controller class. The controller uses the **global layout system** via `renderUnifiedLayoutHeader()` / `renderUnifiedLayoutFooter()`.
File `src/modules/my-module/MyController.php`:
```php
<?php
class MyController {
protected $viewsPath;
protected $layoutsPath;
public function __construct() {
$this->viewsPath = __DIR__ . '/views';
$this->layoutsPath = MAIN_HOME . 'public/Views/layouts/';
require_once $this->layoutsPath . 'admin.php';
require_once $this->layoutsPath . '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 {
// API actions (POST) — no layout needed
$action = $_GET['sub'] ?? '';
// ...
echo json_encode(['result' => true]);
exit;
}
}
```
### Layout rules
| Rule | Description |
|------|-------------|
| **viewsPath** | Always `__DIR__ . '/views'` — the controller is inside the module directory |
| **layoutsPath** | `MAIN_HOME . 'public/Views/layouts/'` — shared across all modules |
| **GET pages** | Must call `renderUnifiedLayoutHeader()` before and `renderUnifiedLayoutFooter()` after the view |
| **API actions** | No layout — return JSON directly |
| **Scripts include** | Module-specific JS is loaded via `<module>_scripts.php` after the footer |
> **Important:** Use `__DIR__ . '/views'` for viewsPath — **not** `dirname(__DIR__) . '/modules/...'`. The controller file is already inside the module directory.
> `renderUnifiedLayoutHeader('admin', [...])` and `renderUnifiedLayoutFooter('admin')` are defined in `public/Views/layouts/admin.php` and `footer.php`. They extract the necessary global variables (`$rSettings`, `$rUserInfo`, `$db`, etc.) and render the shared admin header/footer.
---
## Step 5. Add a cron task (optional)
### 5.1 Cron class (logic) — in the module
File `src/modules/my-module/MyCron.php`:
```php
<?php
class MyCron {
public static function run(): void {
$items = Database::query("SELECT * FROM my_table WHERE status = 'pending'");
foreach ($items as $item) {
self::processItem($item);
}
}
private static function processItem(array $item): void {
// Process item
}
}
```
### 5.2 CronJob wrapper — in the module directory
File `src/modules/my-module/MyCronJob.php`:
```php
<?php
require_once MAIN_HOME . 'cli/CronTrait.php';
class MyCronJob implements CommandInterface {
use CronTrait;
public function getName(): string {
return 'cron:my_task';
}
public function getDescription(): string {
return 'Cron: task description';
}
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;
}
}
```
### 5.3 Registration in the module
Commands are registered **explicitly** in `registerCommands()`:
```php
public function registerCommands(CommandRegistry $registry): void {
$registry->register(new MyCronJob());
}
```
> **Important:** Filesystem scanning of modules is not used. Each module knows its own commands and registers them in `registerCommands()`.
### 5.4 Add to crontab
In `src/cli/Commands/StartupCommand.php` method `installCrontab()`, add:
```php
$rCrons[] = '*/5 * * * * ' . PHP_BIN . ' ' . MAIN_HOME . 'console.php cron:my_task # XC_VM';
```
---
## Step 6. Build configuration (Makefile)
The `modules/` directory is **not** included in `LB_DIRS` — all modules are only present in MAIN builds by default. Module files (crons, commands, views) are automatically excluded from LoadBalancer builds.
---
## Complete examples
### Minimal module (no crons, no routes)
Example: `fingerprint`, `theft-detection`, `magscan`.
```
modules/my-module/
├── module.json
└── MyModule.php
```
`module.json`:
```json
{
"name": "my-module",
"version": "1.0.0",
"requires_core": ">=2.0"
}
```
`MyModule.php` — implements all `ModuleInterface` methods. Methods without behavior are left empty.
### Full module (services + routes + commands + events)
Example: `plex`, `watch`.
```
modules/my-module/
├── module.json
├── MyModule.php
├── MyService.php
├── MyRepository.php
├── MyController.php
├── MyCron.php
├── MyCronJob.php
└── views/
├── my_page.php
└── my_page_scripts.php
```
All module files live inside its directory. CronJob wrappers are registered via `registerCommands()`.
Controllers use the global layout system — see [Step 4a](#step-4a-create-a-controller-optional) for the pattern.
### Module with events
```php
public function getEventSubscribers(): array {
return [
'stream.started' => [MyHandler::class, 'onStreamStarted'],
'stream.stopped' => [MyHandler::class, 'onStreamStopped'],
'user.connected' => [MyHandler::class, 'onUserConnected'],
];
}
```
---
## Module addition checklist
- [ ] Create directory `src/modules/<name>/`
- [ ] Create `module.json` (`name`, `version`, `requires_core`)
- [ ] Create `<Name>Module.php` (implements `ModuleInterface`)
- [ ] (If crons) Create `<Name>Cron.php` + `<Name>CronJob.php` in the module
- [ ] (If crons) Register in `registerCommands()`
- [ ] (If crons) Add to crontab via `StartupCommand`
- [ ] (If pages) Create controller with `renderUnifiedLayoutHeader/Footer`
- [ ] (If pages) Create `views/` directory with page templates
- [ ] (If pages) Register routes in `registerRoutes()` (and temporarily in `public/routes/admin.php`)
- [ ] Verify: `php -l src/modules/<name>/<Name>Module.php`
- [ ] Verify: module loads with `php console.php --list`
- [ ] Verify: removing module directory causes no fatal error
---
## Available core events
| Event | Description | Data |
|-------|-------------|------|
| `stream.started` | Stream started | `['stream_id' => int]` |
| `stream.stopped` | Stream stopped | `['stream_id' => int]` |
| `user.connected` | User connected | `['user_id' => int, 'stream_id' => int]` |
| `cache.rebuilt` | Cache rebuilt | `[]` |
---
## FAQ
**Q: How do I disable a module?**
A: In `src/config/modules.php` add `'module-name' => ['enabled' => false]`.
**Q: Do I need to register the module in config?**
A: No. `ModuleLoader` automatically discovers all modules from `modules/*/module.json`. Config is only needed for disabling.
**Q: My module depends on another module — how?**
A: **Do not allow inter-module dependencies.** A module depends only on `core/` and `domain/`. If shared functionality is needed — extract it to core.
**Q: Can I use `$db` directly?**
A: Technically yes (via `global $db`), but architecturally correct is to use `Database` through `ServiceContainer` or Repository.
**Q: How does a module access settings?**
A: Via `SettingsManager::getAll()['my_key']`. Module settings keys are stored in the shared `settings` table.
**Q: My module is MAIN-only — what do I do?**
A: All modules are already MAIN-only by default — `modules/` is not included in `LB_DIRS`.