Files
XC_VM/docs/en/development/module-extension-points.md
T

381 lines
14 KiB
Markdown
Raw Normal View History

# 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
---