# Module Extension Points The core extension points a module plugs into: the DI container, stream middleware, cron tasks, versioned migrations, and typed events. To author a module see [Module Authoring](module-authoring.md); for load/lifecycle see [Module Lifecycle](module-lifecycle.md). ## DI container and service decoration Services are registered in `boot()` via `ServiceContainer`. The container supports: - **`set(id, factory)`** — lazy singleton via callable, or direct value - **`factory(id, callable)`** — new instance on every `get()` - **`decorate(id, callable, priority)`** — wrap an existing service ```php // Decorate a service (adds behaviour around the original) $container->decorate('stream.encoder', function (mixed $inner, ServiceContainer $c): MyEncoder { return new MyEncoder($inner, $c->get('settings')); }, priority: 20); ``` Decorators are chained by priority (highest wraps outermost). Protected services (`db`, `settings`, `config`, `auth`) cannot be decorated — any attempt throws `RuntimeException`. ### PSR-11 compliance `ServiceContainer` implements `ContainerInterface`: ```php public function get(string $id): mixed; // throws NotFoundException if missing public function has(string $id): bool; ``` `NotFoundException` implements `NotFoundExceptionInterface extends ContainerExceptionInterface`. --- ## PSR-14 events Modules subscribe to typed events via `getEventSubscribers()` or the `#[ListensTo]` attribute. This is documented in full — dispatch, listener registration, priorities, stoppable events and the built-in event catalogue — on the dedicated [Event System](event-system.md) page. --- ## Stream Middleware Modules can inject middleware into the stream pipeline by implementing `StreamMiddlewareProviderInterface` (separate from `ModuleInterface`): ```php class MyStreamMiddleware implements StreamMiddlewareInterface { public function getPriority(): int { return 50; } public function handle(StreamContext $ctx, callable $next): StreamContext { // before — read or set attributes $ctx->set('my.key', 'value'); $ctx = $next($ctx); // after return $ctx; } } ``` `StreamContext` is an attribute bag (`get`, `set`, `has`, `abort`, `isAborted`). `StreamPipeline` executes middleware sorted by `getPriority()` descending. ### Pipeline priorities | Range | Owner | | ---------- | ----------------- | | `80–100` | Core (Auth, Permission, ConnectionLimit) | | `0–79` | Modules | ### Reserved navbar slots | Parent node | Module slots | | ------------------- | ------------------ | | `management.service_setup` | `order` ≥ 60 | | `logs.system` | `order` ≥ 50 | | `profile` | `order` 100–980 | Logs are a top-level `logs` tab with the sub-groups `logs.connections`, `logs.streams`, `logs.system`, `logs.users` — attach an operational module log under `logs.system`. Attaching a child to a parent key that does not exist silently drops it, so keep these keys in sync with `CoreNavbarProvider`. --- ## Topbar buttons (`TopbarProviderInterface`) The per-page **topbar** (the primary action button plus the related-tools dropdown above a page) is assembled by `XcVm\Core\Util\Topbar`. Core pages come from Topbar's own list; a module contributes its buttons through `TopbarProviderInterface::registerTopbar(TopbarRegistry $registry)`, called in the same boot phase as `registerNavbar()`. `BaseModule` ships a no-op default, so override it only when you need topbar buttons. A module can do **both** of these, in one `registerTopbar()`: - **Inject buttons into an existing core page** — pass that page's key (e.g. `movies`); your buttons are appended after the core ones. - **Define a brand-new page of its own** — pass a page key core does not know (e.g. `watch`); the whole topbar for that page comes from your module. ```php use XcVm\Core\Module\TopbarRegistry; public function registerTopbar(TopbarRegistry $registry): void { // add($page, $label, $url = null, $permission = null, $attr = null, $order = 100) // A page the module owns — first entry becomes the primary button. $registry->add('watch', 'Add Folder', 'watch_add', 'folder_watch_add', null, 10); $registry->add('watch', 'Settings', 'settings_watch', 'folder_watch_settings', null, 20); // JS-only action: no url, carry an onClick via $attr. $registry->add('watch', 'Kill Running', null, 'folder_watch_settings', 'onClick="killWatchFolder();"', 40); // Inject a button into an existing CORE page. $registry->add('movies', 'Watch Folder', 'watch', 'folder_watch', null, 200); } ``` **Page key** is `AdminHelpers::getPageName()` for the page the button appears on — the same value Topbar matches against. **Entry shape** mirrors core's `[url, permission, attr]`: | Arg | Meaning | | --- | --- | | `$url` | Target page/URL. `null` for a JS-only action (pair with `$attr`). | | `$permission` | `adv` sub-permission gating the button. `null` = always shown. | | `$attr` | Raw extra attributes: `onClick="…"`, or a well-known `id="…"`. | | `$order` | Sort order **among a page's module entries** (ascending). | **Ordering & the primary button.** Within a page, core entries come first, then module entries sorted by `$order`. `Topbar::items()` marks the first permission-surviving entry as the **primary** button; the rest fall into the dropdown. On a page the module owns outright, its lowest-`$order` entry is the primary. **Permission filtering.** Every entry with a non-null `$permission` is dropped unless `Authorization::check('adv', $permission)` passes, so buttons never leak to roles that lack the right. **Well-known action ids** are wired generically by the shell (`footer.php`) and gated by core, so a module only needs to emit the id: | `id="…"` | Effect | | --- | --- | | `btn-export-csv` / `btn-export-json` | Report export — only rendered on a core-listed log/report page **and** with the `backups` permission. | | `btn-clear-logs` | Clear-logs modal — the log type comes from core's `LOG_TYPES` map for the page. | Re-registering the same `(page, label)` overrides the earlier entry (last-wins), matching `NavbarRegistry`. --- ## Table data (`TableProviderInterface`) A serverSide DataTable posts its `id` to the admin `./table` endpoint (`TableController`). Core table ids are a hard-coded switch; a module serves its OWN table id through `TableProviderInterface::registerTables(TableRegistry $registry)` (same boot phase as the others), so the builder lives in the module instead of core. When `./table` gets an id that is not a core case, it looks it up in the registry. `BaseModule` ships a no-op default. ```php use XcVm\Core\Module\TableRegistry; public function registerTables(TableRegistry $registry): void { $registry->register('watch_output', [WatchController::class, 'tableWatchOutput']); } ``` **Handler contract** — `fn(array $return, int $start, int $limit, bool $isApi): array`: ```php public static function tableWatchOutput(array $rReturn, int $rStart, int $rLimit, bool $rIsAPI): array { global $db; // same access the core handlers use if (!Authorization::check('adv', 'folder_watch_output')) { return $rReturn; // empty skeleton = no access } // …read RequestManager params, run COUNT + paged SELECT… $rReturn['recordsTotal'] = $rTotal; $rReturn['recordsFiltered'] = $rTotal; foreach ($rRows as $rRow) { // Return CLEAN, KEYED JSON — never HTML. The view renders every cell. $rReturn['data'][] = ['id' => (int) $rRow['id'], 'status' => (int) $rRow['status'], /* … */]; } return $rReturn; // do NOT echo/exit — TableController encodes it } ``` Rules: - The handler receives the response skeleton (`recordsTotal`, `recordsFiltered`, `data`) and returns it populated. It must **not** `echo` or `exit` — `TableController` JSON-encodes the returned array. - Return **clean, keyed JSON rows — no server-built HTML**. Status badges, action buttons and links are rendered client-side by the view (the same convention the core tables follow), which keeps presentation out of the controller and lets permission-dependent cells use flags emitted by the view. - For the REST API branch (`$isApi`), reuse `TableController::filterRow($row, $show, $hide)` for column include/exclude. - The DataTables ajax `d.id` in the view must match the registered id. --- ## Reseller permissions (`PermissionProviderInterface`) The group editor's reseller sub-permission catalogue (`XcVm\Core\Reference\PermissionReference`) is a core list. A module adds its OWN permission keys through `PermissionProviderInterface::registerPermissions(PermissionRegistry $registry)` (same boot phase) so the module owns the permissions it gates on, instead of them being hard-coded in core. Keys are merged after the core list. ```php use XcVm\Core\Module\PermissionRegistry; public function registerPermissions(PermissionRegistry $registry): void { $registry->add('folder_watch'); $registry->add('folder_watch_output'); } ``` Each key shows in the editor with labels from the Translator — add `permission_` and `permission__text` language entries. Gate routes, navbar and topbar items on the key exactly as with a core permission (`Authorization::check('adv', 'folder_watch')`); enforcement reads the stored group permissions and is unaffected by where the key is declared. > **Owning a module table end-to-end.** A module log/data table is fully the > module's: build its rows via `TableProviderInterface` (clean JSON), and keep > its delete / clear / import bookkeeping in the module too — expose module > `->api(...)` routes for row actions, and react to a core **event** (e.g. > `VodImportedEvent`) with `#[ListensTo]` instead of core writing the table > directly. Core must never `DELETE`/`UPDATE`/`TRUNCATE` a module-owned table > (it may not exist once the module is uninstalled). --- ## Quick Tools (`QuickToolsProviderInterface`) The admin Quick Tools page is a grid of one-shot maintenance buttons; each posts its key to `post.php?action=quick_tools`, which runs the matching action. Both the button list and the handlers are core. A module adds its OWN tool — button **and** action — via `QuickToolsProviderInterface::registerQuickTools(QuickToolsRegistry $registry)`. ```php use XcVm\Core\Module\QuickToolsRegistry; public function registerQuickTools(QuickToolsRegistry $registry): void { // add($group, $key, $label, $handler) $registry->add('logs', 'clear_watch_logs', 'clear_watch_logs', static function (): void { WatchService::clearAllLogs(); // do the work; query via global $db }); } ``` - `$group` is an existing tab key (`streams`, `lines`, `logs`, `general`, …) — the tool is appended to it — or a new key, rendered as a new tab with a generic icon and `$group` as its label key. - `$label` is a translation key for the button. - `$handler` (`fn(): void`) performs the action and must **not** echo/exit — `post.php` emits the standard `{result:true}` success JSON after it runs. --- ## Cron task **Cron logic** (`MyCron.php`) — business logic only, no CLI wiring. **CronJob wrapper** (`MyCronJob.php`) — implements `CommandInterface`, uses `CronTrait`: ```php class MyCronJob implements CommandInterface { use CronTrait; public function getName(): string { return 'cron:my_task'; } public function getDescription(): string { return 'Cron: my 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; } } ``` Registration in the module: ```php public function registerCommands(CommandRegistry $registry): void { $registry->register(new MyCronJob()); } ``` Declare the crontab entry by overriding `getCronEntries()` in the module class: ```php public function getCronEntries(): array { return [ '*/5 * * * *' => 'cron:my_task', ]; } ``` `ModuleLoader::collectCronEntries()` aggregates all modules' entries and `StartupCommand` / `StatusCommand` write them to the system crontab automatically — no core file changes needed. **Format:** key = cron expression, value = console command name registered via `registerCommands()`. --- ## Versioned migrations (MigratableInterface) > **Two mechanisms, both additive.** The **file-based schema** described under > [Module directory structure](module-authoring.md#module-directory-structure) (`database.sql` master + > `database_drop.sql` teardown + `migrations/.sql` deltas) is the default for > plain DDL/seed. `MigratableInterface` below is the **programmatic** path for upgrade > steps that need PHP logic (data backfills, conditional changes). A module can use > either or both; `ModuleManager::updateModule()` runs the file deltas first, then the > callable migrations. Modules whose upgrades need PHP logic implement `MigratableInterface`: ```php namespace XcVm\Module\MyModule; use BaseModule; use MigratableInterface; use ServiceContainer; class MyModuleModule extends BaseModule implements MigratableInterface { public function getMigrations(): array { return [ '1.1.0' => function (): void { // runs when upgrading from any version < 1.1.0 to >= 1.1.0 global $db; $db->query("ALTER TABLE xc_my_table ADD COLUMN new_col INT DEFAULT 0"); }, '1.2.0' => function (): void { // runs when upgrading from < 1.2.0 to >= 1.2.0 }, ]; } } ``` `ModuleManager::updateModule()` reads `installed_version` from the override store, filters the map to only the entries `> fromVersion && <= toVersion`, sorts by semver, and runs each callable in its own DB transaction. `installModule()` records `installed_version` after a successful install; `uninstallModule()` clears it. **Key rules:** - Keys are semver strings (`'1.1.0'`, `'2.0.0'`) — `version_compare` ordering is used - Each migration runs in its own transaction — failure rolls back only that step - `BaseModule` provides a default `getMigrations(): array { return []; }` so implementing `MigratableInterface` is optional ---