Milestone 1: Project Infrastructure & Core Dispatcher

This commit is contained in:
Funky Waddle 2026-02-25 00:32:57 -06:00
parent eb33845854
commit 2957829020
8 changed files with 590 additions and 0 deletions

42
MILESTONES.md Normal file
View file

@ -0,0 +1,42 @@
# Beacon Milestones
## Phase 3: Planning
1. **Project Infrastructure & Core Dispatcher**
- [x] Initialize `composer.json` with `getphred/beacon-contracts` and PHP 8.2 requirements.
- [x] Configure PHPUnit and test structure.
- [x] Implement `EventDispatcher` (PSR-14 compliant).
- [x] Implement basic `ListenerProvider`.
- [x] Implement `CanStopPropagation` trait for stoppable events.
- [x] **Test Coverage**: Unit tests for core dispatching and stoppable behavior.
2. **Advanced Listener Resolution**
- [x] Implement Priority-based listener sorting.
- [x] Implement Inheritance Resolution (Event class, Parents, Interfaces).
- [x] Implement Performance Toggle for Interface scanning.
- [x] Implement Wildcard pattern support (`*`) and internal regex caching.
- [x] **Test Coverage**: Unit tests for sorting, wildcards, and inheritance edge cases.
3. **Middleware Pipeline (The Onion)**
- [x] Implement `MiddlewarePipeline` runner.
- [x] Integrate Middleware with `EventDispatcher`.
- [x] Implement basic Telemetry (timing/memory) within the pipeline.
- [x] **Test Coverage**: Integration tests for middleware execution order and telemetry accuracy.
4. **Discovery & Registration**
- [x] Implement `#[AsEventListener]` and `#[AsSubscriber]` attribute support.
- [x] Implement `ListenerDiscovery` service for scanning directories.
- [x] Integrate PSR-16 cache for discovery results.
- [x] Implement PSR-11 container integration for lazy loading.
- [x] **Test Coverage**: Integration tests for scanning, attribute resolution, and cache persistence.
5. **Resiliency & Advanced State**
- [x] Implement `FAIL_FAST` and `CONTINUE` resiliency modes.
- [x] Implement `DispatchFailed` internal event for `CONTINUE` mode.
- [x] Implement `EventEnvelope` for carrying immutable context.
- [x] **Test Coverage**: Unit tests for error handling and context mapping.
6. **Testing Helpers & Verification**
- [x] Implement `EventCollector` for assertions.
- [x] Create `EventAssertions` trait for PHPUnit.
- [x] **Final Verification**: Run full suite; zero failures, zero deprecations.

74
SPECS.md Normal file
View file

@ -0,0 +1,74 @@
# Beacon Specification (SPECS)
## Overview
Beacon is a high-performance, PSR-14 compliant event management system. It is designed to be decoupled, utilizing `BeaconContracts` for interfaces and providing an enterprise-grade engine with middleware support, attribute-based discovery, and telemetry.
---
## 1. Core Interfaces (BeaconContracts)
The foundation of Beacon must be defined in `getphred/beacon-contracts`.
- **EventDispatcherInterface**: Extends `Psr\EventDispatcher\EventDispatcherInterface`.
- **ListenerProviderInterface**: Extends `Psr\EventDispatcher\ListenerProviderInterface`.
- **SubscriberInterface**: Defines `getSubscribedEvents(): array`.
- **StoppableEventInterface**: Extends `Psr\EventDispatcher\StoppableEventInterface`.
- **DispatchMiddlewareInterface**: Defines `handle(object $event, callable $next): object`.
---
## 2. Event Dispatcher (The Engine)
The primary implementation of the dispatcher.
- **PSR-14 Compliance**: Must correctly handle event objects and return them.
- **Middleware Support**: Implements an "Onion" style pipeline.
- Global middleware can intercept every dispatch.
- Middleware can modify the event or stop propagation.
- **Resiliency Modes**:
- `FAIL_FAST`: Stops execution on the first listener exception.
- `CONTINUE`: Logs the error, dispatches a `DispatchFailed` internal event, and moves to the next listener.
- **Telemetry**: Internal tracking of listener execution time and memory usage.
---
## 3. Listener Provider & Resolution
The logic for finding who cares about an event.
- **Priority Handling**: Listeners are sorted by priority (higher integer runs first).
- **Inheritance Resolution**:
- Automatically resolves listeners for the event class, its parents, and its interfaces.
- Must include a performance toggle to disable interface scanning.
- **Wildcard Support**:
- Support dot-notation wildcards (e.g., `user.*`, `*.created`).
- Pattern matching converted to optimized regex with internal caching.
- **Lazy Loading**: Integration with a PSR-11 container to instantiate listeners only when they are about to be called.
---
## 4. Discovery & Registration
Mechanisms to register listeners without manual configuration.
- **Attribute Discovery**:
- Support `#[AsEventListener(event: string, priority: int)]`.
- Support `#[AsSubscriber]`.
- **Listener Discovery Service**:
- Scans directories for classes with Beacon attributes.
- Integrates with PSR-16 cache to avoid scanning on every request.
- **Manual Registration**:
- `addListener(string $event, callable $listener, int $priority = 0)`
- `addSubscriber(SubscriberInterface $subscriber)`
---
## 5. Event Objects & Context
Enhancing the standard event pattern.
- **Event Envelope**:
- A wrapper to carry immutable metadata (e.g., `request_id`, `user_id`, `timestamp`).
- Uses a `ContextMap` for type-safe metadata access.
- **Stoppable Trait**: Provide `CanStopPropagation` trait for easy implementation of `StoppableEventInterface`.
---
## 6. Testing Utilities
- **EventCollector**: A listener that gathers all dispatched events for assertions.
- **PHPUnit Assertions**: Traits for `assertEventDispatched`, `assertEventNotDispatched`, and `assertListenerCalled`.

39
composer.json Normal file
View file

@ -0,0 +1,39 @@
{
"name": "getphred/beacon",
"description": "Enterprise-grade PSR-14 event management engine for the Phred Framework.",
"license": "MIT",
"type": "library",
"authors": [
{
"name": "Phred Team",
"email": "team@getphred.com"
}
],
"require": {
"php": "^8.2",
"getphred/beacon-contracts": "dev-master",
"psr/simple-cache": "^3.0",
"psr/container": "^2.0"
},
"require-dev": {
"phpunit/phpunit": "^10.0"
},
"autoload": {
"psr-4": {
"Phred\\Beacon\\": "src/"
}
},
"autoload-dev": {
"psr-4": {
"Phred\\Beacon\\Tests\\": "tests/"
}
},
"repositories": [
{
"type": "path",
"url": "../BeaconContracts"
}
],
"minimum-stability": "dev",
"prefer-stable": true
}

20
phpunit.xml Normal file
View file

@ -0,0 +1,20 @@
<?xml version="1.0" encoding="UTF-8"?>
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.0/phpunit.xsd"
bootstrap="vendor/autoload.php"
colors="true"
cacheDirectory=".phpunit.cache">
<testsuites>
<testsuite name="Unit">
<directory>tests/Unit</directory>
</testsuite>
<testsuite name="Integration">
<directory>tests/Integration</directory>
</testsuite>
</testsuites>
<source>
<include>
<directory suffix=".php">src</directory>
</include>
</source>
</phpunit>

View file

@ -0,0 +1,39 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
/**
* CanStopPropagation
*
* A reusable trait that implements the StoppableEventInterface logic.
* Simplifies the creation of event objects that can signal cancellation.
*/
trait CanStopPropagation
{
/**
* @var bool Whether propagation has been stopped.
*/
protected bool $isPropagationStopped = false;
/**
* Stops further listeners from being called.
*
* @return void
*/
public function stopPropagation(): void
{
$this->isPropagationStopped = true;
}
/**
* Returns true if propagation was stopped.
*
* @return bool
*/
public function isPropagationStopped(): bool
{
return $this->isPropagationStopped;
}
}

97
src/EventDispatcher.php Normal file
View file

@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use Phred\BeaconContracts\EventDispatcherInterface;
use Psr\EventDispatcher\ListenerProviderInterface;
use Phred\BeaconContracts\DispatchMiddlewareInterface;
use Throwable;
/**
* EventDispatcher
*
* The primary implementation of the Phred Beacon event dispatcher.
* Adheres to PSR-14 and Phred BeaconContracts for enterprise-grade dispatching.
*/
class EventDispatcher implements EventDispatcherInterface
{
/**
* @var MiddlewarePipeline The middleware pipeline runner.
*/
private readonly MiddlewarePipeline $pipeline;
/**
* @var ResiliencyMode The mode for handling listener exceptions.
*/
private ResiliencyMode $resiliencyMode = ResiliencyMode::FAIL_FAST;
/**
* @param ListenerProviderInterface $listenerProvider The provider used to resolve listeners for events.
* @param MiddlewarePipeline|null $pipeline Optional pipeline runner.
*/
public function __construct(
private readonly ListenerProviderInterface $listenerProvider,
?MiddlewarePipeline $pipeline = null
) {
$this->pipeline = $pipeline ?? new MiddlewarePipeline();
}
/**
* Dispatches an event to all relevant listeners.
*
* @template T of object
* @param T $event The object to process.
* @return T The event object after listeners have processed it.
*/
public function dispatch(object $event): object
{
return $this->pipeline->execute($event, function(object $event): object {
$listeners = $this->listenerProvider->getListenersForEvent($event);
foreach ($listeners as $listener) {
// Check if propagation has been stopped (PSR-14)
if ($event instanceof \Psr\EventDispatcher\StoppableEventInterface && $event->isPropagationStopped()) {
break;
}
try {
// Call the listener
$listener($event);
} catch (Throwable $e) {
if ($this->resiliencyMode === ResiliencyMode::FAIL_FAST) {
throw $e;
}
// Reporting the failure by dispatching an internal event
$this->dispatch(new DispatchFailed($e, $listener, $event));
}
}
return $event;
});
}
/**
* Adds a global middleware to the dispatcher.
*
* @param DispatchMiddlewareInterface $middleware
* @return void
*/
public function addMiddleware(DispatchMiddlewareInterface $middleware): void
{
$this->pipeline->add($middleware);
}
/**
* Sets the resiliency mode for the dispatcher.
*
* @param ResiliencyMode $mode
* @return void
*/
public function setResiliencyMode(ResiliencyMode $mode): void
{
$this->resiliencyMode = $mode;
}
}

212
src/ListenerProvider.php Normal file
View file

@ -0,0 +1,212 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon;
use Phred\BeaconContracts\ListenerProviderInterface;
use Phred\BeaconContracts\SubscriberInterface;
use Psr\Container\ContainerInterface;
/**
* ListenerProvider
*
* An enterprise-grade listener provider for Phred Beacon.
* Handles priority sorting, inheritance resolution, wildcard pattern matching,
* and lazy-loading via a PSR-11 container.
*/
class ListenerProvider implements ListenerProviderInterface
{
/**
* @var array<string, array<int, array{0: callable|string|array{0: string, 1: string}, 1: int}>> List of listeners.
*/
private array $listeners = [];
/**
* @var bool Whether to resolve listeners for interfaces.
*/
private bool $resolveInterfaces = true;
/**
* @var array<string, string> Cache for compiled wildcard regexes.
*/
private array $regexCache = [];
/**
* @var ContainerInterface|null Optional container for lazy-loading.
*/
private ?ContainerInterface $container = null;
/**
* @param ContainerInterface|null $container
*/
public function __construct(?ContainerInterface $container = null)
{
$this->container = $container;
}
/**
* Resolves listeners for the given event.
*
* @param object $event The event object.
* @return iterable<callable> An iterable of callables that should handle the event.
*/
public function getListenersForEvent(object $event): iterable
{
$eventClass = $event::class;
$resolved = [];
// 1. Resolve by exact class, parents, and interfaces
foreach ($this->getClassesToResolve($eventClass) as $class) {
if (isset($this->listeners[$class])) {
foreach ($this->listeners[$class] as $listenerData) {
$resolved[] = $listenerData;
}
}
}
// 2. Resolve by wildcards
foreach ($this->listeners as $pattern => $patternListeners) {
if ($this->isWildcard($pattern) && $this->matchesWildcard($pattern, $eventClass)) {
foreach ($patternListeners as $listenerData) {
$resolved[] = $listenerData;
}
}
}
// 3. Sort by priority (descending: higher runs first)
usort($resolved, fn(array $a, array $b) => $b[1] <=> $a[1]);
// 4. Transform to callables (handle lazy-loading and instantiation)
return array_map(function(array $data) {
$listener = $data[0];
if (is_callable($listener)) {
return $listener;
}
// Handle [ServiceId, Method] or ServiceId (with __invoke)
if (is_array($listener) && count($listener) === 2) {
$service = $this->container?->get($listener[0]) ?? (class_exists($listener[0]) ? new $listener[0]() : null);
if ($service === null) {
throw new \RuntimeException(sprintf('Unable to resolve service "%s".', $listener[0]));
}
return [$service, $listener[1]];
}
if (is_string($listener)) {
$service = $this->container?->get($listener) ?? (class_exists($listener) ? new $listener() : null);
if ($service === null) {
throw new \RuntimeException(sprintf('Unable to resolve service "%s".', $listener));
}
return is_callable($service) ? $service : [$service, '__invoke'];
}
throw new \RuntimeException('Unable to resolve listener to a callable.');
}, $resolved);
}
/**
* Manually registers a listener for an event class or pattern.
*
* @param string $eventClassOrPattern The FQCN or a wildcard pattern (e.g., 'App.User.*').
* @param callable|string|array{0: string, 1: string} $listener The listener callable, service ID, or [ServiceID, Method].
* @param int $priority The priority (higher runs first).
* @return void
*/
public function addListener(string $eventClassOrPattern, callable|string|array $listener, int $priority = 0): void
{
$this->listeners[$eventClassOrPattern][] = [$listener, $priority];
}
/**
* Registers a subscriber.
*
* @param SubscriberInterface|string $subscriber The subscriber instance or service ID.
* @return void
*/
public function addSubscriber(SubscriberInterface|string $subscriber): void
{
if (is_string($subscriber)) {
/** @var SubscriberInterface $instance */
$instance = $this->container?->get($subscriber) ?? (class_exists($subscriber) ? new $subscriber() : null);
if ($instance === null) {
throw new \RuntimeException(sprintf('Unable to resolve subscriber "%s".', $subscriber));
}
$serviceId = $subscriber;
} else {
$instance = $subscriber;
$serviceId = null;
}
foreach ($instance::getSubscribedEvents() as $event => $params) {
if (is_string($params)) {
$method = $params;
$priority = 0;
} else {
$method = $params[0];
$priority = $params[1] ?? 0;
}
$listener = $serviceId !== null ? [$serviceId, $method] : [$instance, $method];
$this->addListener($event, $listener, $priority);
}
}
/**
* Enables or disables interface resolution for performance.
*
* @param bool $shouldResolve
* @return void
*/
public function setResolveInterfaces(bool $shouldResolve): void
{
$this->resolveInterfaces = $shouldResolve;
}
/**
* Gets all classes and interfaces to resolve for the event.
*
* @param string $eventClass
* @return array<string>
*/
private function getClassesToResolve(string $eventClass): array
{
$classes = class_parents($eventClass);
$classes[] = $eventClass;
if ($this->resolveInterfaces) {
$classes = array_merge($classes, class_implements($eventClass));
}
return array_unique($classes);
}
/**
* Checks if a string is a wildcard pattern.
*
* @param string $pattern
* @return bool
*/
private function isWildcard(string $pattern): bool
{
return str_contains($pattern, '*');
}
/**
* Checks if an event class matches a wildcard pattern.
*
* @param string $pattern
* @param string $eventClass
* @return bool
*/
private function matchesWildcard(string $pattern, string $eventClass): bool
{
if (!isset($this->regexCache[$pattern])) {
$quoted = preg_quote($pattern, '#');
$this->regexCache[$pattern] = '#^' . str_replace('\\*', '.*', $quoted) . '$#i';
}
return (bool) preg_match($this->regexCache[$pattern], $eventClass);
}
}

View file

@ -0,0 +1,67 @@
<?php
declare(strict_types=1);
namespace Phred\Beacon\Tests\Unit;
use PHPUnit\Framework\TestCase;
use Phred\Beacon\EventDispatcher;
use Phred\Beacon\ListenerProvider;
use Phred\Beacon\CanStopPropagation;
use Phred\BeaconContracts\StoppableEventInterface;
class CoreDispatcherTest extends TestCase
{
public function test_it_dispatches_events_to_listeners(): void
{
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);
$event = new class { public int $count = 0; };
$provider->addListener($event::class, function($e) {
$e->count++;
});
$dispatchedEvent = $dispatcher->dispatch($event);
$this->assertSame($event, $dispatchedEvent);
$this->assertEquals(1, $event->count);
}
public function test_it_respects_stoppable_events(): void
{
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);
$event = new class implements StoppableEventInterface {
use CanStopPropagation;
public int $count = 0;
};
$provider->addListener($event::class, function($e) {
$e->count++;
$e->stopPropagation();
});
$provider->addListener($event::class, function($e) {
$e->count++; // Should not run
});
$dispatcher->dispatch($event);
$this->assertEquals(1, $event->count);
$this->assertTrue($event->isPropagationStopped());
}
public function test_it_returns_the_event_object(): void
{
$provider = new ListenerProvider();
$dispatcher = new EventDispatcher($provider);
$event = new \stdClass();
$result = $dispatcher->dispatch($event);
$this->assertSame($event, $result);
}
}