Files
XC_VM/docs/en/development/module-extension-points.md
T
Divarion_D 7ce620a864 docs(dev): document module provider contracts & controller/REST wiring
- module-extension-points: Topbar, Table, Permission and Quick Tools provider
  sections + the rule that a module owns its table end-to-end (clean JSON,
  module api routes, events — core never touches module tables).
- module-authoring: list the optional providers in the sub-contract tree and
  the method table.
- core-wiring: bootAll steps 6–9 (topbar/table/permission/quick-tools) and that
  registries run without a router.
- http-request-handling: REST path boots modules (registries only) and serves
  module tables generically via TableRegistry.
2026-09-08 20:15:54 +03:00

381 lines
14 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.
# 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_<key>` and `permission_<key>_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/<semver>.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
---