From 25d2b5a074be9ee4f6b8d7cd5db90c8d3470f0eb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 12:49:11 +0200 Subject: [PATCH 01/12] Created necessary contracts, factories, and adapters for handling different broker configurations in the SSE system. Added a health check mechanism to ensure each adapter is functioning correctly. --- src/Broker/HealthCheckResult.php | 58 ++++++ src/Broker/InMemoryBrokerAdapterFactory.php | 18 ++ src/Broker/LocalBrokerAdapter.php | 35 ++++ src/Broker/LocalBrokerAdapterFactory.php | 57 ++++++ src/Broker/Mercure/MercureBrokerAdapter.php | 43 +++++ .../Mercure/MercureBrokerAdapterFactory.php | 32 ++++ src/Broker/NullBrokerAdapterFactory.php | 18 ++ src/Broker/Redis/RedisBrokerAdapter.php | 57 ++++++ .../Redis/RedisBrokerAdapterFactory.php | 46 +++++ src/Commands/HealthCheckCommand.php | 75 +++----- src/Config/Services.php | 28 ++- src/Config/Sse.php | 30 ++-- .../BrokerAdapterFactoryInterface.php | 13 ++ src/Contracts/BrokerAdapterInterface.php | 12 ++ src/Contracts/HealthCheckableInterface.php | 12 ++ ...PreflightSubscriptionEndpointInterface.php | 13 ++ .../SubscriberAwareBrokerAdapterInterface.php | 10 ++ .../SubscriptionEndpointInterface.php | 20 +++ src/Factory/BrokerAdapterResolver.php | 168 ++++++++++++++++++ src/Factory/BrokerBuildContext.php | 17 ++ src/Factory/BrokerFactory.php | 139 +++------------ src/Factory/LegacyBrokerAdapterFactory.php | 136 ++++++++++++++ src/HTTP/LocalSseSubscriptionEndpoint.php | 87 +++++++++ src/HTTP/MercureSubscriptionEndpoint.php | 67 +++++++ src/HTTP/SseController.php | 119 ++++--------- tests/Config/ServicesTest.php | 85 +++++++++ 26 files changed, 1137 insertions(+), 258 deletions(-) create mode 100644 src/Broker/HealthCheckResult.php create mode 100644 src/Broker/InMemoryBrokerAdapterFactory.php create mode 100644 src/Broker/LocalBrokerAdapter.php create mode 100644 src/Broker/LocalBrokerAdapterFactory.php create mode 100644 src/Broker/Mercure/MercureBrokerAdapter.php create mode 100644 src/Broker/Mercure/MercureBrokerAdapterFactory.php create mode 100644 src/Broker/NullBrokerAdapterFactory.php create mode 100644 src/Broker/Redis/RedisBrokerAdapter.php create mode 100644 src/Broker/Redis/RedisBrokerAdapterFactory.php create mode 100644 src/Contracts/BrokerAdapterFactoryInterface.php create mode 100644 src/Contracts/BrokerAdapterInterface.php create mode 100644 src/Contracts/HealthCheckableInterface.php create mode 100644 src/Contracts/PreflightSubscriptionEndpointInterface.php create mode 100644 src/Contracts/SubscriberAwareBrokerAdapterInterface.php create mode 100644 src/Contracts/SubscriptionEndpointInterface.php create mode 100644 src/Factory/BrokerAdapterResolver.php create mode 100644 src/Factory/BrokerBuildContext.php create mode 100644 src/Factory/LegacyBrokerAdapterFactory.php create mode 100644 src/HTTP/LocalSseSubscriptionEndpoint.php create mode 100644 src/HTTP/MercureSubscriptionEndpoint.php diff --git a/src/Broker/HealthCheckResult.php b/src/Broker/HealthCheckResult.php new file mode 100644 index 0000000..50efc07 --- /dev/null +++ b/src/Broker/HealthCheckResult.php @@ -0,0 +1,58 @@ + $details + */ + private function __construct( + public string $status, + public string $summary, + public array $details = [], + public ?Throwable $error = null, + ) { + if (! in_array($status, [self::OK, self::FAILED, self::SKIPPED], true)) { + throw new InvalidArgumentException(sprintf('Unknown SSE health check status "%s".', $status)); + } + } + + /** + * @param list $details + */ + public static function ok(string $summary, array $details = []): self + { + return new self(self::OK, $summary, $details); + } + + /** + * @param list $details + */ + public static function failed(string $summary, ?Throwable $error = null, array $details = []): self + { + return new self(self::FAILED, $summary, $details, $error); + } + + /** + * @param list $details + */ + public static function skipped(string $summary, array $details = []): self + { + return new self(self::SKIPPED, $summary, $details); + } + + public function isSuccessful(): bool + { + return $this->status !== self::FAILED; + } +} diff --git a/src/Broker/InMemoryBrokerAdapterFactory.php b/src/Broker/InMemoryBrokerAdapterFactory.php new file mode 100644 index 0000000..5354a4a --- /dev/null +++ b/src/Broker/InMemoryBrokerAdapterFactory.php @@ -0,0 +1,18 @@ +create($config, $context); + } +} diff --git a/src/Broker/LocalBrokerAdapter.php b/src/Broker/LocalBrokerAdapter.php new file mode 100644 index 0000000..68884f6 --- /dev/null +++ b/src/Broker/LocalBrokerAdapter.php @@ -0,0 +1,35 @@ +publisher; + } + + public function subscriber(): SubscriberInterface + { + return $this->subscriber; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } +} diff --git a/src/Broker/LocalBrokerAdapterFactory.php b/src/Broker/LocalBrokerAdapterFactory.php new file mode 100644 index 0000000..97d6854 --- /dev/null +++ b/src/Broker/LocalBrokerAdapterFactory.php @@ -0,0 +1,57 @@ + $brokerClass + */ + public function __construct( + private string $brokerClass, + ) { + } + + public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface + { + if (! class_exists($this->brokerClass)) { + throw new LogicException(sprintf('The configured SSE broker class "%s" does not exist.', $this->brokerClass)); + } + + $broker = new $this->brokerClass(); + + if (! $broker instanceof PublisherInterface || ! $broker instanceof SubscriberInterface) { + throw new LogicException( + sprintf('The configured local SSE broker "%s" must publish and subscribe.', $this->brokerClass), + ); + } + + $manager = new SseConnectionManager( + $broker, + $context->serializer, + $context->events, + $config->heartbeatInterval, + $config->maxConnectionSeconds, + $config->retryMilliseconds, + $config->emitConnectedEvent, + ); + + return new LocalBrokerAdapter( + $broker, + $broker, + new LocalSseSubscriptionEndpoint($manager, $config->requireAcceptHeader), + ); + } +} diff --git a/src/Broker/Mercure/MercureBrokerAdapter.php b/src/Broker/Mercure/MercureBrokerAdapter.php new file mode 100644 index 0000000..a6b724a --- /dev/null +++ b/src/Broker/Mercure/MercureBrokerAdapter.php @@ -0,0 +1,43 @@ +publisher; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } + + public function healthCheck(): HealthCheckResult + { + if (! function_exists('curl_version')) { + return HealthCheckResult::failed('The Mercure publisher requires the PHP cURL extension.'); + } + + return HealthCheckResult::ok( + sprintf('Mercure SSE configuration is valid for %s.', $this->config->hubUrl), + ['Hub readiness is exposed through the Mercure Caddy admin API and is not queried by this command.'], + ); + } +} diff --git a/src/Broker/Mercure/MercureBrokerAdapterFactory.php b/src/Broker/Mercure/MercureBrokerAdapterFactory.php new file mode 100644 index 0000000..6e140f9 --- /dev/null +++ b/src/Broker/Mercure/MercureBrokerAdapterFactory.php @@ -0,0 +1,32 @@ +configs ?? new MercureConfigFactory(); + $mercure = $configs->create($config); + + return new MercureBrokerAdapter( + $mercure, + new MercurePublisher($mercure, $context->serializer), + new MercureSubscriptionEndpoint($config, configs: $configs), + ); + } +} diff --git a/src/Broker/NullBrokerAdapterFactory.php b/src/Broker/NullBrokerAdapterFactory.php new file mode 100644 index 0000000..5d868ea --- /dev/null +++ b/src/Broker/NullBrokerAdapterFactory.php @@ -0,0 +1,18 @@ +create($config, $context); + } +} diff --git a/src/Broker/Redis/RedisBrokerAdapter.php b/src/Broker/Redis/RedisBrokerAdapter.php new file mode 100644 index 0000000..78f7f05 --- /dev/null +++ b/src/Broker/Redis/RedisBrokerAdapter.php @@ -0,0 +1,57 @@ +publisher; + } + + public function subscriber(): SubscriberInterface + { + return $this->subscriber; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } + + public function healthCheck(): HealthCheckResult + { + if (! $this->healthChecker->check()) { + return HealthCheckResult::failed( + sprintf( + 'Redis SSE health check failed for %s (database %d).', + $this->config->endpoint(), + $this->config->database, + ), + $this->healthChecker->lastError(), + ); + } + + return HealthCheckResult::ok( + sprintf('Redis SSE broker is reachable at %s.', $this->config->endpoint()), + ); + } +} diff --git a/src/Broker/Redis/RedisBrokerAdapterFactory.php b/src/Broker/Redis/RedisBrokerAdapterFactory.php new file mode 100644 index 0000000..1610367 --- /dev/null +++ b/src/Broker/Redis/RedisBrokerAdapterFactory.php @@ -0,0 +1,46 @@ +configs ?? new RedisConfigFactory())->create($config); + $connectionFactory = new RedisConnectionFactory($redis); + $publisher = new RedisPublisher($redis, $context->serializer, $connectionFactory); + $subscriber = new RedisSubscriber($redis, $context->serializer, $connectionFactory); + $manager = new SseConnectionManager( + $subscriber, + $context->serializer, + $context->events, + $config->heartbeatInterval, + $config->maxConnectionSeconds, + $config->retryMilliseconds, + $config->emitConnectedEvent, + ); + + return new RedisBrokerAdapter( + $redis, + $publisher, + $subscriber, + new LocalSseSubscriptionEndpoint($manager, $config->requireAcceptHeader), + new RedisHealthChecker(new RedisConnectionFactory($redis)), + ); + } +} diff --git a/src/Commands/HealthCheckCommand.php b/src/Commands/HealthCheckCommand.php index be4394e..1e92886 100644 --- a/src/Commands/HealthCheckCommand.php +++ b/src/Commands/HealthCheckCommand.php @@ -6,9 +6,10 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; +use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Config\Sse; -use Maniaba\CodeIgniterSse\Factory\HealthCheckerFactory; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; +use Maniaba\CodeIgniterSse\Contracts\HealthCheckableInterface; +use Maniaba\CodeIgniterSse\Factory\BrokerFactory; final class HealthCheckCommand extends BaseCommand { @@ -22,56 +23,31 @@ final class HealthCheckCommand extends BaseCommand */ public function run(array $params): int { - $config = Sse::discover(); - - if ($config->streamTransport() === 'mercure') { - $mercure = (new MercureConfigFactory())->create($config); - - if (! function_exists('curl_version')) { - CLI::error('The Mercure publisher requires the PHP cURL extension.'); - - return EXIT_ERROR; - } + $config = Sse::discover(); + $adapter = (new BrokerFactory())->adapter($config); + if (! $adapter instanceof HealthCheckableInterface) { CLI::write( - sprintf( - '[OK] Mercure SSE configuration is valid for %s.', - $mercure->hubUrl, - ), - 'green', - ); - CLI::write( - '[INFO] Hub readiness is exposed through the Mercure Caddy admin API and is not queried by this command.', + sprintf('[SKIPPED] The "%s" SSE broker does not expose a health check.', $config->broker), 'yellow', ); return EXIT_SUCCESS; } - if (strtolower($config->broker) !== 'redis') { - CLI::write( - sprintf('[OK] The "%s" SSE broker does not require a network health check.', $config->broker), - 'green', - ); + return $this->render($adapter->healthCheck()); + } - return EXIT_SUCCESS; - } + private function render(HealthCheckResult $result): int + { + if ($result->status === HealthCheckResult::FAILED) { + CLI::error('[FAILED] ' . $result->summary); - $redis = $config->redis(); - $checker = (new HealthCheckerFactory())->create($config); - - if (! $checker->check()) { - CLI::error( - sprintf( - 'Redis SSE health check failed for %s://%s:%d (database %d).', - $redis['scheme'], - $redis['host'], - $redis['port'], - $redis['database'], - ), - ); + foreach ($result->details as $detail) { + CLI::error('[INFO] ' . $detail); + } - $error = $checker->lastError(); + $error = $result->error; while ($error !== null) { CLI::error($error::class . ': ' . $error->getMessage()); @@ -81,15 +57,14 @@ public function run(array $params): int return EXIT_ERROR; } - CLI::write( - sprintf( - '[OK] Redis SSE broker is reachable at %s://%s:%d.', - $redis['scheme'], - $redis['host'], - $redis['port'], - ), - 'green', - ); + $color = $result->status === HealthCheckResult::SKIPPED ? 'yellow' : 'green'; + $label = $result->status === HealthCheckResult::SKIPPED ? 'SKIPPED' : 'OK'; + + CLI::write(sprintf('[%s] %s', $label, $result->summary), $color); + + foreach ($result->details as $detail) { + CLI::write('[INFO] ' . $detail, 'yellow'); + } return EXIT_SUCCESS; } diff --git a/src/Config/Services.php b/src/Config/Services.php index 6232061..edf51bd 100644 --- a/src/Config/Services.php +++ b/src/Config/Services.php @@ -6,6 +6,7 @@ use CodeIgniter\Config\BaseService; use LogicException; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Factory\BrokerFactory; @@ -57,6 +58,31 @@ public static function ssePublisher( $config ??= Sse::discover(); $config->validate(); - return (new BrokerFactory())->publisher($config); + return (new BrokerFactory())->publisherFromAdapter( + $config, + static::sseBrokerAdapter($config, $getShared), + ); + } + + public static function sseBrokerAdapter( + ?Sse $config = null, + bool $getShared = true, + ): BrokerAdapterInterface { + if ($getShared) { + $service = static::getSharedInstance('sseBrokerAdapter', $config); + + if (! $service instanceof BrokerAdapterInterface) { + throw new LogicException( + 'The shared sseBrokerAdapter service must implement ' . BrokerAdapterInterface::class . '.', + ); + } + + return $service; + } + + $config ??= Sse::discover(); + $config->validate(); + + return (new BrokerFactory())->adapter($config); } } diff --git a/src/Config/Sse.php b/src/Config/Sse.php index e862bf5..313cdee 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -9,11 +9,12 @@ use LogicException; use Maniaba\CodeIgniterSse\Authorization\NullUserResolver; use Maniaba\CodeIgniterSse\Authorization\PublicChannelAuthorizer; -use Maniaba\CodeIgniterSse\Broker\InMemoryBroker; -use Maniaba\CodeIgniterSse\Broker\Mercure\MercurePublisher; -use Maniaba\CodeIgniterSse\Broker\NullBroker; -use Maniaba\CodeIgniterSse\Broker\Redis\RedisPublisher; -use Maniaba\CodeIgniterSse\Broker\Redis\RedisSubscriber; +use Maniaba\CodeIgniterSse\Broker\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\NullBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\ChannelAuthorizerInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; @@ -119,7 +120,9 @@ class Sse extends BaseConfig /** * @var array, + * factory?: BrokerAdapterFactoryInterface|callable(): BrokerAdapterFactoryInterface|class-string, + * adapter?: BrokerAdapterInterface|callable(self): BrokerAdapterInterface|class-string, + * publisher?: callable(self): PublisherInterface|class-string, * subscriber?: callable(self): SubscriberInterface|class-string, * transport?: 'mercure'|'php', * shared?: bool @@ -127,22 +130,19 @@ class Sse extends BaseConfig */ public array $brokers = [ 'redis' => [ - 'publisher' => RedisPublisher::class, - 'subscriber' => RedisSubscriber::class, + 'factory' => RedisBrokerAdapterFactory::class, ], 'mercure' => [ - 'publisher' => MercurePublisher::class, + 'factory' => MercureBrokerAdapterFactory::class, 'transport' => 'mercure', ], 'memory' => [ - 'publisher' => InMemoryBroker::class, - 'subscriber' => InMemoryBroker::class, - 'shared' => true, + 'factory' => InMemoryBrokerAdapterFactory::class, + 'shared' => true, ], 'null' => [ - 'publisher' => NullBroker::class, - 'subscriber' => NullBroker::class, - 'shared' => true, + 'factory' => NullBrokerAdapterFactory::class, + 'shared' => true, ], ]; diff --git a/src/Contracts/BrokerAdapterFactoryInterface.php b/src/Contracts/BrokerAdapterFactoryInterface.php new file mode 100644 index 0000000..cf1f257 --- /dev/null +++ b/src/Contracts/BrokerAdapterFactoryInterface.php @@ -0,0 +1,13 @@ + $channels + */ + public function respond( + RequestInterface $request, + ResponseInterface $response, + array $channels, + ): ResponseInterface; +} diff --git a/src/Factory/BrokerAdapterResolver.php b/src/Factory/BrokerAdapterResolver.php new file mode 100644 index 0000000..78c8e94 --- /dev/null +++ b/src/Factory/BrokerAdapterResolver.php @@ -0,0 +1,168 @@ + + */ + private array $shared = []; + + public function __construct( + private readonly ?SerializerInterface $serializer = null, + private readonly ?EventFactory $events = null, + private readonly ?RedisConfigFactory $redisConfigs = null, + private readonly ?MercureConfigFactory $mercureConfigs = null, + ) { + } + + public function resolve(Sse $config): BrokerAdapterInterface + { + $definition = $this->definition($config); + $shared = ($definition['shared'] ?? false) === true; + + if ($shared) { + $cacheKey = spl_object_id($config) . ':' . $config->broker; + + if (! isset($this->shared[$cacheKey])) { + $this->shared[$cacheKey] = $this->make($config, $definition); + } + + return $this->shared[$cacheKey]; + } + + return $this->make($config, $definition); + } + + /** + * @return array + */ + private function definition(Sse $config): array + { + $definition = $config->brokers[$config->broker] ?? null; + + if (! is_array($definition)) { + throw new LogicException('The configured SSE broker definition must be an array.'); + } + + return $definition; + } + + /** + * @param array $definition + */ + private function make(Sse $config, array $definition): BrokerAdapterInterface + { + if (array_key_exists('adapter', $definition)) { + return $this->makeAdapter($config, $definition['adapter']); + } + + if (array_key_exists('factory', $definition)) { + return $this->makeFactory($definition['factory']) + ->create($config, $this->context()); + } + + return (new LegacyBrokerAdapterFactory( + $this->redisConfigs, + $this->mercureConfigs, + ))->create($config, $this->context()); + } + + private function makeAdapter(Sse $config, mixed $definition): BrokerAdapterInterface + { + if ($definition instanceof Closure) { + $adapter = $definition($config, $this->context()); + + if ($adapter instanceof BrokerAdapterInterface) { + return $adapter; + } + } + + if (is_callable($definition) && ! is_string($definition)) { + $adapter = $definition($config, $this->context()); + + if ($adapter instanceof BrokerAdapterInterface) { + return $adapter; + } + } + + if (is_string($definition)) { + if (! class_exists($definition)) { + throw new LogicException(sprintf('The configured SSE broker adapter "%s" does not exist.', $definition)); + } + + $adapter = new $definition(); + + if ($adapter instanceof BrokerAdapterInterface) { + return $adapter; + } + } + + if ($definition instanceof BrokerAdapterInterface) { + return $definition; + } + + throw new LogicException( + 'The configured SSE broker adapter must implement ' . BrokerAdapterInterface::class . '.', + ); + } + + private function makeFactory(mixed $definition): BrokerAdapterFactoryInterface + { + if ($definition instanceof BrokerAdapterFactoryInterface) { + return $definition; + } + + if ($definition instanceof Closure) { + $factory = $definition(); + + if ($factory instanceof BrokerAdapterFactoryInterface) { + return $factory; + } + } + + if (is_callable($definition) && ! is_string($definition)) { + $factory = $definition(); + + if ($factory instanceof BrokerAdapterFactoryInterface) { + return $factory; + } + } + + if (is_string($definition)) { + if (! class_exists($definition)) { + throw new LogicException(sprintf('The configured SSE broker adapter factory "%s" does not exist.', $definition)); + } + + $factory = new $definition(); + + if ($factory instanceof BrokerAdapterFactoryInterface) { + return $factory; + } + } + + throw new LogicException( + 'The configured SSE broker factory must implement ' . BrokerAdapterFactoryInterface::class . '.', + ); + } + + private function context(): BrokerBuildContext + { + return new BrokerBuildContext( + $this->serializer ?? new JsonEventSerializer(), + $this->events ?? new EventFactory(), + ); + } +} diff --git a/src/Factory/BrokerBuildContext.php b/src/Factory/BrokerBuildContext.php new file mode 100644 index 0000000..265ec54 --- /dev/null +++ b/src/Factory/BrokerBuildContext.php @@ -0,0 +1,17 @@ + - */ - private static array $shared = []; - - public function __construct( - private readonly ?SerializerInterface $serializer = null, - private readonly ?RedisConfigFactory $redisConfigs = null, - private readonly ?MercureConfigFactory $mercureConfigs = null, - private readonly ?bool $enableToolbarTracing = null, - ) { - } - - public function publisher(Sse $config): PublisherInterface + public function __construct(private readonly ?SerializerInterface $serializer = null, private readonly ?RedisConfigFactory $redisConfigs = null, private readonly ?MercureConfigFactory $mercureConfigs = null, private readonly ?bool $enableToolbarTracing = null, private readonly ?EventFactory $events = null, private ?BrokerAdapterResolver $resolver = null) { - $publisher = $this->broker($config, 'publisher'); - - if (! $publisher instanceof PublisherInterface) { - throw new LogicException('The configured SSE publisher must implement ' . PublisherInterface::class . '.'); - } - - return $this->tracePublisher($config, $publisher); } - public function subscriber(Sse $config): SubscriberInterface + public function adapter(Sse $config): BrokerAdapterInterface { - $subscriber = $this->broker($config, 'subscriber'); - - if (! $subscriber instanceof SubscriberInterface) { - throw new LogicException('The configured SSE subscriber must implement ' . SubscriberInterface::class . '.'); - } - - return $subscriber; + return $this->resolver()->resolve($config); } - private function broker(Sse $config, string $role): object + public function publisher(Sse $config): PublisherInterface { - $definition = $config->brokers[$config->broker] ?? null; - - if (! is_array($definition)) { - throw new LogicException('The configured SSE broker definition must be an array.'); - } - - if (($definition['shared'] ?? false) === true) { - $publisher = $definition['publisher'] ?? null; - $subscriber = $definition['subscriber'] ?? null; - $sharedKey = $publisher !== null && $publisher === $subscriber ? 'broker' : $role; - $cacheKey = spl_object_id($config) . ':' . $config->broker . ':' . $sharedKey; - - if (! isset(self::$shared[$cacheKey])) { - self::$shared[$cacheKey] = $this->make($config, $definition[$role] ?? null, $role); - } - - return self::$shared[$cacheKey]; - } - - return $this->make($config, $definition[$role] ?? null, $role); + return $this->publisherFromAdapter($config, $this->adapter($config)); } - private function make(Sse $config, mixed $definition, string $role): object + public function publisherFromAdapter(Sse $config, BrokerAdapterInterface $adapter): PublisherInterface { - if ($definition instanceof Closure) { - return $definition($config); - } - - if (is_callable($definition) && ! is_string($definition)) { - return $definition($config); - } - - if (is_string($definition)) { - return $this->makeClass($config, $definition); - } - - throw new LogicException(sprintf('The SSE %s broker definition is invalid.', $role)); + return $this->tracePublisher($config, $adapter->publisher()); } - private function makeClass(Sse $config, string $class): object + public function subscriber(Sse $config): SubscriberInterface { - if (! class_exists($class)) { - throw new LogicException(sprintf('The configured SSE broker class "%s" does not exist.', $class)); - } + $adapter = $this->adapter($config); - if (is_a($class, RedisPublisher::class, true)) { - return new $class( - $this->redisConfig($config), - $this->serializer(), - $this->redisConnectionFactory($config), - ); - } - - if (is_a($class, RedisSubscriber::class, true)) { - return new $class( - $this->redisConfig($config), - $this->serializer(), - $this->redisConnectionFactory($config), - ); + if (! $adapter instanceof SubscriberAwareBrokerAdapterInterface) { + throw new LogicException('The configured SSE broker does not provide a PHP subscriber.'); } - if (is_a($class, MercurePublisher::class, true)) { - return new $class( - $this->mercureConfig($config), - $this->serializer(), - ); - } - - return new $class(); - } - - private function serializer(): SerializerInterface - { - return $this->serializer ?? new JsonEventSerializer(); + return $adapter->subscriber(); } - private function redisConfig(Sse $config): RedisConfig + public function subscriptionEndpoint(Sse $config): SubscriptionEndpointInterface { - return ($this->redisConfigs ?? new RedisConfigFactory())->create($config); + return $this->adapter($config)->subscriptionEndpoint(); } - private function mercureConfig(Sse $config): MercureConfig + private function resolver(): BrokerAdapterResolver { - return ($this->mercureConfigs ?? new MercureConfigFactory())->create($config); - } + if ($this->resolver === null) { + $this->resolver = new BrokerAdapterResolver( + $this->serializer, + $this->events, + $this->redisConfigs, + $this->mercureConfigs, + ); + } - private function redisConnectionFactory(Sse $config): RedisConnectionFactory - { - return new RedisConnectionFactory($this->redisConfig($config)); + return $this->resolver; } private function tracePublisher(Sse $config, PublisherInterface $publisher): PublisherInterface diff --git a/src/Factory/LegacyBrokerAdapterFactory.php b/src/Factory/LegacyBrokerAdapterFactory.php new file mode 100644 index 0000000..6813f4a --- /dev/null +++ b/src/Factory/LegacyBrokerAdapterFactory.php @@ -0,0 +1,136 @@ +brokers[$config->broker] ?? null; + + if (! is_array($definition)) { + throw new LogicException('The configured SSE broker definition must be an array.'); + } + + $publisher = $this->make($config, $context, $definition['publisher'] ?? null, 'publisher'); + + if (! $publisher instanceof PublisherInterface) { + throw new LogicException('The configured SSE publisher must implement ' . PublisherInterface::class . '.'); + } + + if (($definition['transport'] ?? null) === 'mercure') { + $configs = $this->mercureConfigs ?? new MercureConfigFactory(); + + return new MercureBrokerAdapter( + $configs->create($config), + $publisher, + new MercureSubscriptionEndpoint($config, configs: $configs), + ); + } + + $subscriberDefinition = $definition['subscriber'] ?? null; + $subscriber = ($definition['shared'] ?? false) === true + && ($definition['publisher'] ?? null) === $subscriberDefinition + && $publisher instanceof SubscriberInterface + ? $publisher + : $this->make($config, $context, $subscriberDefinition, 'subscriber'); + + if (! $subscriber instanceof SubscriberInterface) { + throw new LogicException('The configured SSE subscriber must implement ' . SubscriberInterface::class . '.'); + } + + $manager = new SseConnectionManager( + $subscriber, + $context->serializer, + $context->events, + $config->heartbeatInterval, + $config->maxConnectionSeconds, + $config->retryMilliseconds, + $config->emitConnectedEvent, + ); + + return new LocalBrokerAdapter( + $publisher, + $subscriber, + new LocalSseSubscriptionEndpoint($manager, $config->requireAcceptHeader), + ); + } + + private function make(Sse $config, BrokerBuildContext $context, mixed $definition, string $role): object + { + if ($definition instanceof Closure) { + return $definition($config, $context); + } + + if (is_callable($definition) && ! is_string($definition)) { + return $definition($config, $context); + } + + if (is_string($definition)) { + return $this->makeClass($config, $context, $definition); + } + + throw new LogicException(sprintf('The SSE %s broker definition is invalid.', $role)); + } + + private function makeClass(Sse $config, BrokerBuildContext $context, string $class): object + { + if (! class_exists($class)) { + throw new LogicException(sprintf('The configured SSE broker class "%s" does not exist.', $class)); + } + + if (is_a($class, RedisPublisher::class, true)) { + $redis = ($this->redisConfigs ?? new RedisConfigFactory())->create($config); + + return new $class( + $redis, + $context->serializer, + new RedisConnectionFactory($redis), + ); + } + + if (is_a($class, RedisSubscriber::class, true)) { + $redis = ($this->redisConfigs ?? new RedisConfigFactory())->create($config); + + return new $class( + $redis, + $context->serializer, + new RedisConnectionFactory($redis), + ); + } + + if (is_a($class, MercurePublisher::class, true)) { + return new $class( + ($this->mercureConfigs ?? new MercureConfigFactory())->create($config), + $context->serializer, + ); + } + + return new $class(); + } +} diff --git a/src/HTTP/LocalSseSubscriptionEndpoint.php b/src/HTTP/LocalSseSubscriptionEndpoint.php new file mode 100644 index 0000000..7377eb8 --- /dev/null +++ b/src/HTTP/LocalSseSubscriptionEndpoint.php @@ -0,0 +1,87 @@ +requireAcceptHeader || $this->acceptsEventStream($request)) { + return null; + } + + return $this->error( + $response, + 406, + 'not_acceptable', + 'This endpoint requires Accept: text/event-stream.', + ); + } + + public function respond( + RequestInterface $request, + ResponseInterface $response, + array $channels, + ): ResponseInterface { + $factory = $this->responseFactory ?? new SseResponseFactory($response); + + $response = $factory->create( + function (SseOutputInterface $output) use ($channels): void { + $this->manager->stream($output, $channels); + }, + ); + $response->setHeader('X-Content-Type-Options', 'nosniff'); + + return $response; + } + + private function acceptsEventStream(RequestInterface $request): bool + { + $accept = strtolower($request->getHeaderLine('Accept')); + + if ($accept === '') { + return false; + } + + foreach (explode(',', $accept) as $mediaRange) { + $mediaType = trim(explode(';', $mediaRange, 2)[0]); + + if ($mediaType === 'text/event-stream' || $mediaType === '*/*') { + return true; + } + } + + return false; + } + + private function error( + ResponseInterface $response, + int $status, + string $code, + string $message, + ): ResponseInterface { + return $response + ->setStatusCode($status) + ->setJSON([ + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + ]); + } +} diff --git a/src/HTTP/MercureSubscriptionEndpoint.php b/src/HTTP/MercureSubscriptionEndpoint.php new file mode 100644 index 0000000..cfdc597 --- /dev/null +++ b/src/HTTP/MercureSubscriptionEndpoint.php @@ -0,0 +1,67 @@ +subscriptions ?? new MercureSubscriptionFactory()) + ->create($this->config, $channels); + $mercure = ($this->configs ?? new MercureConfigFactory())->create($this->config); + + $response = $response + ->setStatusCode(200) + ->setJSON([ + 'transport' => 'mercure', + 'hub' => $subscription->hubUrl, + 'topics' => $subscription->topics, + 'expiresAt' => $subscription->expiresAt, + ]) + ->setHeader('Cache-Control', 'private, no-store') + ->setHeader('Link', sprintf('<%s>; rel="mercure"', $subscription->hubUrl)) + ->setHeader('X-Content-Type-Options', 'nosniff'); + + if ($subscription->token !== null) { + $response->setCookie( + name: $mercure->cookieName, + value: $subscription->token, + expire: $mercure->subscriberTokenTtl, + domain: $mercure->cookieDomain, + path: $mercure->cookiePath, + secure: $mercure->cookieSecure, + httponly: $mercure->cookieHttpOnly, + samesite: $mercure->cookieSameSite, + ); + + return $response; + } + + $response->deleteCookie( + $mercure->cookieName, + $mercure->cookieDomain, + $mercure->cookiePath, + ); + + return $response; + } +} diff --git a/src/HTTP/SseController.php b/src/HTTP/SseController.php index 45f9c69..5032dd2 100644 --- a/src/HTTP/SseController.php +++ b/src/HTTP/SseController.php @@ -7,14 +7,16 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\RESTful\ResourceController; use Maniaba\CodeIgniterSse\Config\Sse as SseConfig; -use Maniaba\CodeIgniterSse\Contracts\SseOutputInterface; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use Maniaba\CodeIgniterSse\Exception\InvalidChannelRequestException; use Maniaba\CodeIgniterSse\Exception\InvalidOriginException; use Maniaba\CodeIgniterSse\Exception\UnauthorizedChannelException; use Maniaba\CodeIgniterSse\Factory\AuthorizationFactory; +use Maniaba\CodeIgniterSse\Factory\BrokerFactory; use Maniaba\CodeIgniterSse\Factory\ConnectionManagerFactory; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; @@ -27,6 +29,7 @@ public function __construct( private readonly ?ConnectionManagerFactory $connectionManagers = null, private readonly ?MercureSubscriptionFactory $mercureSubscriptions = null, private readonly ?SseConfig $config = null, + private readonly ?BrokerFactory $brokers = null, ) { } @@ -42,19 +45,14 @@ public function stream(): ResponseInterface return $this->error(403, 'origin_forbidden', $exception->getMessage()); } - if ($config->streamTransport() === 'mercure') { - return $cors->apply($this->mercure($config), $origin); - } + $endpoint = $this->subscriptionEndpoint($config); - if ($config->requireAcceptHeader && ! $this->acceptsEventStream()) { - return $cors->apply( - $this->error( - 406, - 'not_acceptable', - 'This endpoint requires Accept: text/event-stream.', - ), - $origin, - ); + if ($endpoint instanceof PreflightSubscriptionEndpointInterface) { + $preflight = $endpoint->preflight($this->request, $this->response); + + if ($preflight !== null) { + return $cors->apply($preflight, $origin); + } } try { @@ -71,65 +69,10 @@ public function stream(): ResponseInterface ); } - $manager = $this->manager - ?? ($this->connectionManagers ?? new ConnectionManagerFactory())->create($config); - $factory = $this->responseFactory ?? new SseResponseFactory($this->response); - - $response = $factory->create( - static function (SseOutputInterface $output) use ($manager, $channels): void { - $manager->stream($output, $channels); - }, + return $cors->apply( + $endpoint->respond($this->request, $this->response, $channels), + $origin, ); - $response->setHeader('X-Content-Type-Options', 'nosniff'); - - return $cors->apply($response, $origin); - } - - private function mercure(SseConfig $config): ResponseInterface - { - try { - $channels = $this->authorizeChannels($config); - } catch (InvalidChannelException|InvalidChannelRequestException $exception) { - return $this->error(400, 'invalid_channels', $exception->getMessage()); - } catch (UnauthorizedChannelException $exception) { - return $this->error(403, 'channel_forbidden', $exception->getMessage()); - } - - $subscription = ($this->mercureSubscriptions ?? new MercureSubscriptionFactory()) - ->create($config, $channels); - $mercure = (new MercureConfigFactory())->create($config); - $response = $this->response - ->setStatusCode(200) - ->setJSON([ - 'transport' => 'mercure', - 'hub' => $subscription->hubUrl, - 'topics' => $subscription->topics, - 'expiresAt' => $subscription->expiresAt, - ]) - ->setHeader('Cache-Control', 'private, no-store') - ->setHeader('Link', sprintf('<%s>; rel="mercure"', $subscription->hubUrl)) - ->setHeader('X-Content-Type-Options', 'nosniff'); - - if ($subscription->token !== null) { - $response->setCookie( - name: $mercure->cookieName, - value: $subscription->token, - expire: $mercure->subscriberTokenTtl, - domain: $mercure->cookieDomain, - path: $mercure->cookiePath, - secure: $mercure->cookieSecure, - httponly: $mercure->cookieHttpOnly, - samesite: $mercure->cookieSameSite, - ); - } else { - $response->deleteCookie( - $mercure->cookieName, - $mercure->cookieDomain, - $mercure->cookiePath, - ); - } - - return $response; } /** @@ -149,23 +92,37 @@ private function authorizeChannels(SseConfig $config): array return $authorization->authorizeAll($userResolver->resolve(), $channels); } - private function acceptsEventStream(): bool + private function subscriptionEndpoint(SseConfig $config): SubscriptionEndpointInterface { - $accept = strtolower($this->request->getHeaderLine('Accept')); + if ($this->manager !== null) { + return new LocalSseSubscriptionEndpoint( + $this->manager, + $config->requireAcceptHeader, + $this->responseFactory, + ); + } + + if ($this->connectionManagers !== null) { + return new LocalSseSubscriptionEndpoint( + $this->connectionManagers->create($config), + $config->requireAcceptHeader, + $this->responseFactory, + ); + } - if ($accept === '') { - return false; + if ($this->mercureSubscriptions !== null && $config->streamTransport() === 'mercure') { + return new MercureSubscriptionEndpoint($config, $this->mercureSubscriptions); } - foreach (explode(',', $accept) as $mediaRange) { - $mediaType = trim(explode(';', $mediaRange, 2)[0]); + if ($this->config === null && $this->brokers === null) { + $adapter = service('sseBrokerAdapter'); - if ($mediaType === 'text/event-stream' || $mediaType === '*/*') { - return true; + if ($adapter instanceof BrokerAdapterInterface) { + return $adapter->subscriptionEndpoint(); } } - return false; + return ($this->brokers ?? new BrokerFactory())->subscriptionEndpoint($config); } private function error(int $status, string $code, string $message): ResponseInterface diff --git a/tests/Config/ServicesTest.php b/tests/Config/ServicesTest.php index e3abd23..b9d5096 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -5,6 +5,8 @@ namespace Tests\Config; use CodeIgniter\Config\Services as FrameworkServices; +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Test\CIUnitTestCase; use Maniaba\CodeIgniterSse\Broker\InMemoryBroker; use Maniaba\CodeIgniterSse\Broker\Mercure\MercurePublisher; @@ -14,13 +16,18 @@ use Maniaba\CodeIgniterSse\Broker\Redis\RedisPublisher; use Maniaba\CodeIgniterSse\Config\Services; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\EventInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; +use Maniaba\CodeIgniterSse\Contracts\SubscriberAwareBrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; +use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; use Maniaba\CodeIgniterSse\Debug\Toolbar\SseEventHistory; use Maniaba\CodeIgniterSse\Debug\Toolbar\TraceablePublisher; use Maniaba\CodeIgniterSse\Event\SseEvent; use Maniaba\CodeIgniterSse\Factory\AuthorizationFactory; +use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; use Maniaba\CodeIgniterSse\Factory\BrokerFactory; use Maniaba\CodeIgniterSse\Sse as SseManager; use ReflectionProperty; @@ -160,6 +167,73 @@ public function subscribe( $this->assertSame($subscriber, $brokers->subscriber($config)); } + public function testCustomBrokerCanBeConfiguredWithAnAdapterFactory(): void + { + $publisher = new class () implements PublisherInterface { + public function publish(string $channel, EventInterface $event): void + { + } + }; + + $endpoint = new class () implements SubscriptionEndpointInterface { + /** + * @var list + */ + public array $channels = []; + + public function respond( + RequestInterface $request, + ResponseInterface $response, + array $channels, + ): ResponseInterface { + $this->channels = $channels; + + return $response->setStatusCode(204); + } + }; + + $adapter = new class ($publisher, $endpoint) implements BrokerAdapterInterface { + public function __construct( + private readonly PublisherInterface $publisher, + private readonly SubscriptionEndpointInterface $endpoint, + ) { + } + + public function publisher(): PublisherInterface + { + return $this->publisher; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } + }; + + $factory = new class ($adapter) implements BrokerAdapterFactoryInterface { + public function __construct( + private readonly BrokerAdapterInterface $adapter, + ) { + } + + public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface + { + return $this->adapter; + } + }; + + $config = new Sse(); + $config->broker = 'custom-adapter'; + $config->brokers['custom-adapter'] = [ + 'factory' => $factory, + ]; + + $brokers = new BrokerFactory(); + + $this->assertSame($publisher, $brokers->publisher($config)); + $this->assertSame($endpoint, $brokers->subscriptionEndpoint($config)); + } + public function testCustomBrokerCanUseSimpleClassNames(): void { $config = new Sse(); @@ -241,6 +315,17 @@ public function testToolbarTracingIgnoresBrokersThatAreNotConfiguredForTracing() $this->assertSame([], SseEventHistory::all()); } + public function testBrokerAdapterServiceCanCreateSharedLocalAdapters(): void + { + $config = new Sse(); + $config->broker = 'memory'; + + $adapter = Services::sseBrokerAdapter($config, false); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertSame($adapter->publisher(), $adapter->subscriber()); + } + public function testConvenienceServiceUsesApplicationPublisherOverride(): void { $publisher = new class () implements PublisherInterface { From acbf0f9703e953bc8b7b7950ecb2f0694b64b895 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 12:59:35 +0200 Subject: [PATCH 02/12] Added basic broker adapter, factory, and tests to handle different broker configurations in the SSE system. --- src/Broker/LocalBrokerAdapterFactory.php | 3 - src/Broker/Mercure/MercureBrokerAdapter.php | 3 +- tests/Broker/HealthCheckResultTest.php | 61 +++++ tests/Broker/LocalBrokerAdapterTest.php | 93 +++++++ .../Mercure/MercureBrokerAdapterTest.php | 104 +++++++ tests/Broker/Redis/RedisBrokerAdapterTest.php | 118 ++++++++ tests/Factory/BrokerAdapterResolverTest.php | 237 ++++++++++++++++ .../LegacyBrokerAdapterFactoryTest.php | 255 ++++++++++++++++++ tests/HTTP/SubscriptionEndpointTest.php | 193 +++++++++++++ tests/Support/Adapter/BasicBrokerAdapter.php | 37 +++ .../Adapter/BasicBrokerAdapterFactory.php | 29 ++ .../Adapter/BasicSubscriptionEndpoint.php | 27 ++ tests/Support/Adapter/InvalidAdapter.php | 9 + .../Adapter/InvalidBrokerAdapterFactory.php | 9 + tests/Support/Adapter/PublisherOnly.php | 15 ++ tests/Support/Adapter/RecordingBroker.php | 51 ++++ 16 files changed, 1240 insertions(+), 4 deletions(-) create mode 100644 tests/Broker/HealthCheckResultTest.php create mode 100644 tests/Broker/LocalBrokerAdapterTest.php create mode 100644 tests/Broker/Mercure/MercureBrokerAdapterTest.php create mode 100644 tests/Broker/Redis/RedisBrokerAdapterTest.php create mode 100644 tests/Factory/BrokerAdapterResolverTest.php create mode 100644 tests/Factory/LegacyBrokerAdapterFactoryTest.php create mode 100644 tests/HTTP/SubscriptionEndpointTest.php create mode 100644 tests/Support/Adapter/BasicBrokerAdapter.php create mode 100644 tests/Support/Adapter/BasicBrokerAdapterFactory.php create mode 100644 tests/Support/Adapter/BasicSubscriptionEndpoint.php create mode 100644 tests/Support/Adapter/InvalidAdapter.php create mode 100644 tests/Support/Adapter/InvalidBrokerAdapterFactory.php create mode 100644 tests/Support/Adapter/PublisherOnly.php create mode 100644 tests/Support/Adapter/RecordingBroker.php diff --git a/src/Broker/LocalBrokerAdapterFactory.php b/src/Broker/LocalBrokerAdapterFactory.php index 97d6854..1bc5279 100644 --- a/src/Broker/LocalBrokerAdapterFactory.php +++ b/src/Broker/LocalBrokerAdapterFactory.php @@ -16,9 +16,6 @@ final readonly class LocalBrokerAdapterFactory implements BrokerAdapterFactoryInterface { - /** - * @param class-string $brokerClass - */ public function __construct( private string $brokerClass, ) { diff --git a/src/Broker/Mercure/MercureBrokerAdapter.php b/src/Broker/Mercure/MercureBrokerAdapter.php index a6b724a..b37587b 100644 --- a/src/Broker/Mercure/MercureBrokerAdapter.php +++ b/src/Broker/Mercure/MercureBrokerAdapter.php @@ -16,6 +16,7 @@ public function __construct( private MercureConfig $config, private PublisherInterface $publisher, private SubscriptionEndpointInterface $endpoint, + private ?bool $hasCurl = null, ) { } @@ -31,7 +32,7 @@ public function subscriptionEndpoint(): SubscriptionEndpointInterface public function healthCheck(): HealthCheckResult { - if (! function_exists('curl_version')) { + if (! ($this->hasCurl ?? function_exists('curl_version'))) { return HealthCheckResult::failed('The Mercure publisher requires the PHP cURL extension.'); } diff --git a/tests/Broker/HealthCheckResultTest.php b/tests/Broker/HealthCheckResultTest.php new file mode 100644 index 0000000..ac4f4ef --- /dev/null +++ b/tests/Broker/HealthCheckResultTest.php @@ -0,0 +1,61 @@ +assertSame(HealthCheckResult::OK, $result->status); + $this->assertSame('reachable', $result->summary); + $this->assertSame(['detail'], $result->details); + $this->assertNull($result->error); + $this->assertTrue($result->isSuccessful()); + } + + public function testCreatesFailedResult(): void + { + $error = new RuntimeException('offline'); + $result = HealthCheckResult::failed('failed', $error, ['host']); + + $this->assertSame(HealthCheckResult::FAILED, $result->status); + $this->assertSame('failed', $result->summary); + $this->assertSame(['host'], $result->details); + $this->assertSame($error, $result->error); + $this->assertFalse($result->isSuccessful()); + } + + public function testCreatesSkippedResult(): void + { + $result = HealthCheckResult::skipped('not supported'); + + $this->assertSame(HealthCheckResult::SKIPPED, $result->status); + $this->assertTrue($result->isSuccessful()); + } + + public function testRejectsUnknownStatus(): void + { + $reflection = new ReflectionClass(HealthCheckResult::class); + $constructor = $reflection->getConstructor(); + $this->assertNotNull($constructor); + $result = $reflection->newInstanceWithoutConstructor(); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unknown SSE health check status "unknown".'); + + $constructor->invoke($result, 'unknown', 'bad status'); + } +} diff --git a/tests/Broker/LocalBrokerAdapterTest.php b/tests/Broker/LocalBrokerAdapterTest.php new file mode 100644 index 0000000..eb3acf9 --- /dev/null +++ b/tests/Broker/LocalBrokerAdapterTest.php @@ -0,0 +1,93 @@ +assertSame($publisher, $adapter->publisher()); + $this->assertSame($subscriber, $adapter->subscriber()); + $this->assertSame($endpoint, $adapter->subscriptionEndpoint()); + } + + public function testLocalFactoryCreatesSharedPublisherSubscriberEndpoint(): void + { + RecordingBroker::reset(); + + $adapter = (new LocalBrokerAdapterFactory(RecordingBroker::class)) + ->create(new Sse(), $this->context()); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertInstanceOf(RecordingBroker::class, $adapter->publisher()); + $this->assertSame($adapter->publisher(), $adapter->subscriber()); + $this->assertInstanceOf(LocalSseSubscriptionEndpoint::class, $adapter->subscriptionEndpoint()); + $this->assertSame(1, RecordingBroker::$constructed); + } + + public function testLocalFactoryRejectsMissingClass(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE broker class "MissingBroker" does not exist.'); + + (new LocalBrokerAdapterFactory('MissingBroker'))->create(new Sse(), $this->context()); + } + + public function testLocalFactoryRejectsClassWithoutSubscriberSide(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must publish and subscribe'); + + (new LocalBrokerAdapterFactory(PublisherOnly::class))->create(new Sse(), $this->context()); + } + + public function testBuiltInLocalFactoriesCreateExpectedBrokerTypes(): void + { + $context = $this->context(); + + $memory = (new InMemoryBrokerAdapterFactory())->create(new Sse(), $context); + $null = (new NullBrokerAdapterFactory())->create(new Sse(), $context); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $memory); + $this->assertInstanceOf(InMemoryBroker::class, $memory->publisher()); + $this->assertSame($memory->publisher(), $memory->subscriber()); + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $null); + $this->assertInstanceOf(NullBroker::class, $null->publisher()); + $this->assertSame($null->publisher(), $null->subscriber()); + } + + private function context(): BrokerBuildContext + { + return new BrokerBuildContext(new JsonEventSerializer(), new EventFactory()); + } +} diff --git a/tests/Broker/Mercure/MercureBrokerAdapterTest.php b/tests/Broker/Mercure/MercureBrokerAdapterTest.php new file mode 100644 index 0000000..5ced2b1 --- /dev/null +++ b/tests/Broker/Mercure/MercureBrokerAdapterTest.php @@ -0,0 +1,104 @@ +mercureConfig(), $publisher, $endpoint, true); + + $this->assertSame($publisher, $adapter->publisher()); + $this->assertSame($endpoint, $adapter->subscriptionEndpoint()); + } + + public function testHealthCheckReportsValidConfiguration(): void + { + $adapter = new MercureBrokerAdapter( + $this->mercureConfig(), + new RecordingPublisher(), + new BasicSubscriptionEndpoint(), + true, + ); + + $result = $adapter->healthCheck(); + + $this->assertSame(HealthCheckResult::OK, $result->status); + $this->assertSame( + 'Mercure SSE configuration is valid for http://mercure/.well-known/mercure.', + $result->summary, + ); + $this->assertSame( + ['Hub readiness is exposed through the Mercure Caddy admin API and is not queried by this command.'], + $result->details, + ); + } + + public function testHealthCheckReportsMissingCurlExtension(): void + { + $adapter = new MercureBrokerAdapter( + $this->mercureConfig(), + new RecordingPublisher(), + new BasicSubscriptionEndpoint(), + false, + ); + + $result = $adapter->healthCheck(); + + $this->assertSame(HealthCheckResult::FAILED, $result->status); + $this->assertSame('The Mercure publisher requires the PHP cURL extension.', $result->summary); + } + + public function testFactoryCreatesMercureAdapter(): void + { + $adapter = (new MercureBrokerAdapterFactory())->create( + $this->config(), + new BrokerBuildContext(new JsonEventSerializer(), new EventFactory()), + ); + + $this->assertInstanceOf(MercureBrokerAdapter::class, $adapter); + $this->assertInstanceOf(MercurePublisher::class, $adapter->publisher()); + $this->assertInstanceOf(MercureSubscriptionEndpoint::class, $adapter->subscriptionEndpoint()); + } + + private function mercureConfig(): MercureConfig + { + return (new MercureConfigFactory())->create($this->config()); + } + + private function config(): Sse + { + $config = new Sse(); + $config->broker = 'mercure'; + $config->mercure = [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'publisherKey' => 'publisher-test-secret', + 'subscriberKey' => 'subscriber-test-secret', + ]; + + return $config; + } +} diff --git a/tests/Broker/Redis/RedisBrokerAdapterTest.php b/tests/Broker/Redis/RedisBrokerAdapterTest.php new file mode 100644 index 0000000..c649c19 --- /dev/null +++ b/tests/Broker/Redis/RedisBrokerAdapterTest.php @@ -0,0 +1,118 @@ +create(new Sse()); + $publisher = new RecordingPublisher(); + $subscriber = new RecordingSubscriber(); + $endpoint = new BasicSubscriptionEndpoint(); + $checker = new RedisHealthChecker(new FakeRedisConnectionFactory([new FakeRedisConnection()])); + $adapter = new RedisBrokerAdapter($redis, $publisher, $subscriber, $endpoint, $checker); + + $this->assertSame($publisher, $adapter->publisher()); + $this->assertSame($subscriber, $adapter->subscriber()); + $this->assertSame($endpoint, $adapter->subscriptionEndpoint()); + } + + public function testHealthCheckReportsReachableRedis(): void + { + $redis = (new RedisConfigFactory())->create(new Sse()); + $connection = new FakeRedisConnection(); + $adapter = new RedisBrokerAdapter( + $redis, + new RecordingPublisher(), + new RecordingSubscriber(), + new BasicSubscriptionEndpoint(), + new RedisHealthChecker(new FakeRedisConnectionFactory([$connection])), + ); + + $result = $adapter->healthCheck(); + + $this->assertSame(HealthCheckResult::OK, $result->status); + $this->assertSame('Redis SSE broker is reachable at tcp://127.0.0.1:6379.', $result->summary); + $this->assertNull($result->error); + $this->assertSame(1, $connection->pingCalls); + $this->assertSame(1, $connection->closeCalls); + } + + public function testHealthCheckReportsConnectionFailure(): void + { + $config = new Sse(); + $config->redis = [ + 'host' => 'redis.internal', + 'port' => 6380, + 'database' => 4, + ]; + $redis = (new RedisConfigFactory())->create($config); + $connection = new FakeRedisConnection(); + $error = new RedisConnectionException('offline'); + $connection->connectFailure = $error; + $adapter = new RedisBrokerAdapter( + $redis, + new RecordingPublisher(), + new RecordingSubscriber(), + new BasicSubscriptionEndpoint(), + new RedisHealthChecker(new FakeRedisConnectionFactory([$connection])), + ); + + $result = $adapter->healthCheck(); + + $this->assertSame(HealthCheckResult::FAILED, $result->status); + $this->assertSame( + 'Redis SSE health check failed for tcp://redis.internal:6380 (database 4).', + $result->summary, + ); + $this->assertSame($error, $result->error); + $this->assertSame(1, $connection->closeCalls); + } + + public function testFactoryCreatesRedisAdapterWithoutOpeningAConnection(): void + { + $config = new Sse(); + $config->redis = ['host' => 'redis.internal']; + $config->requireAcceptHeader = false; + $config->emitConnectedEvent = false; + $config->retryMilliseconds = 1500; + $config->heartbeatInterval = 5; + $config->maxConnectionSeconds = 10; + + $adapter = (new RedisBrokerAdapterFactory())->create( + $config, + new BrokerBuildContext(new JsonEventSerializer(), new EventFactory()), + ); + + $this->assertInstanceOf(RedisBrokerAdapter::class, $adapter); + $this->assertInstanceOf(RedisPublisher::class, $adapter->publisher()); + $this->assertInstanceOf(RedisSubscriber::class, $adapter->subscriber()); + $this->assertInstanceOf(LocalSseSubscriptionEndpoint::class, $adapter->subscriptionEndpoint()); + } +} diff --git a/tests/Factory/BrokerAdapterResolverTest.php b/tests/Factory/BrokerAdapterResolverTest.php new file mode 100644 index 0000000..7ea0954 --- /dev/null +++ b/tests/Factory/BrokerAdapterResolverTest.php @@ -0,0 +1,237 @@ +config(['adapter' => $adapter]); + + $this->assertSame($adapter, (new BrokerAdapterResolver())->resolve($config)); + } + + public function testResolvesConfiguredAdapterClosure(): void + { + $adapter = new BasicBrokerAdapter(); + $config = $this->config([ + 'adapter' => static fn (Sse $config, BrokerBuildContext $context): BrokerAdapterInterface => $adapter, + ]); + + $this->assertSame($adapter, (new BrokerAdapterResolver())->resolve($config)); + } + + public function testResolvesConfiguredAdapterCallableObject(): void + { + $adapter = new BasicBrokerAdapter(); + $config = $this->config([ + 'adapter' => new class ($adapter) { + public function __construct( + private readonly BrokerAdapterInterface $adapter, + ) { + } + + public function __invoke(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface + { + return $this->adapter; + } + }, + ]); + + $this->assertSame($adapter, (new BrokerAdapterResolver())->resolve($config)); + } + + public function testResolvesConfiguredAdapterClass(): void + { + $config = $this->config(['adapter' => BasicBrokerAdapter::class]); + + $this->assertInstanceOf(BasicBrokerAdapter::class, (new BrokerAdapterResolver())->resolve($config)); + } + + public function testRejectsMissingAdapterClass(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE broker adapter "MissingAdapter" does not exist.'); + + (new BrokerAdapterResolver())->resolve($this->config(['adapter' => 'MissingAdapter'])); + } + + public function testRejectsAdapterClassThatDoesNotImplementTheContract(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must implement ' . BrokerAdapterInterface::class); + + (new BrokerAdapterResolver())->resolve($this->config(['adapter' => InvalidAdapter::class])); + } + + public function testRejectsAdapterClosureThatReturnsInvalidValue(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must implement ' . BrokerAdapterInterface::class); + + (new BrokerAdapterResolver())->resolve($this->config([ + 'adapter' => static fn (): stdClass => new stdClass(), + ])); + } + + public function testResolvesConfiguredFactoryInstance(): void + { + $adapter = new BasicBrokerAdapter(); + $factory = new BasicBrokerAdapterFactory(); + BasicBrokerAdapterFactory::$adapter = $adapter; + + $this->assertSame($adapter, (new BrokerAdapterResolver())->resolve($this->config([ + 'factory' => $factory, + ]))); + } + + public function testResolvesConfiguredFactoryClosure(): void + { + $adapter = new BasicBrokerAdapter(); + BasicBrokerAdapterFactory::$adapter = $adapter; + + $this->assertSame($adapter, (new BrokerAdapterResolver())->resolve($this->config([ + 'factory' => static fn (): BrokerAdapterFactoryInterface => new BasicBrokerAdapterFactory(), + ]))); + } + + public function testResolvesConfiguredFactoryCallableObject(): void + { + $adapter = new BasicBrokerAdapter(); + BasicBrokerAdapterFactory::$adapter = $adapter; + + $factoryProvider = new class () { + public function __invoke(): BrokerAdapterFactoryInterface + { + return new BasicBrokerAdapterFactory(); + } + }; + + $this->assertSame($adapter, (new BrokerAdapterResolver())->resolve($this->config([ + 'factory' => $factoryProvider, + ]))); + } + + public function testResolvesConfiguredFactoryClass(): void + { + $config = $this->config(['factory' => BasicBrokerAdapterFactory::class]); + + $this->assertInstanceOf(BasicBrokerAdapter::class, (new BrokerAdapterResolver())->resolve($config)); + } + + public function testRejectsMissingFactoryClass(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE broker adapter factory "MissingFactory" does not exist.'); + + (new BrokerAdapterResolver())->resolve($this->config(['factory' => 'MissingFactory'])); + } + + public function testRejectsFactoryClassThatDoesNotImplementTheContract(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must implement ' . BrokerAdapterFactoryInterface::class); + + (new BrokerAdapterResolver())->resolve($this->config(['factory' => InvalidBrokerAdapterFactory::class])); + } + + public function testRejectsFactoryCallableThatReturnsInvalidValue(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must implement ' . BrokerAdapterFactoryInterface::class); + + (new BrokerAdapterResolver())->resolve($this->config([ + 'factory' => static fn (): stdClass => new stdClass(), + ])); + } + + public function testSharedDefinitionsReuseTheResolvedAdapter(): void + { + $config = $this->config([ + 'factory' => BasicBrokerAdapterFactory::class, + 'shared' => true, + ]); + $resolver = new BrokerAdapterResolver(); + + $first = $resolver->resolve($config); + $second = $resolver->resolve($config); + + $this->assertSame($first, $second); + $this->assertSame(1, BasicBrokerAdapterFactory::$created); + } + + public function testNonSharedDefinitionsCreateANewAdapterEachTime(): void + { + $config = $this->config(['factory' => BasicBrokerAdapterFactory::class]); + $resolver = new BrokerAdapterResolver(); + + $this->assertNotSame($resolver->resolve($config), $resolver->resolve($config)); + $this->assertSame(2, BasicBrokerAdapterFactory::$created); + } + + public function testRejectsNonArrayBrokerDefinition(): void + { + $config = new Sse(); + $config->broker = 'invalid'; + (new ReflectionProperty($config, 'brokers'))->setValue($config, ['invalid' => 'not-array']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE broker definition must be an array.'); + + (new BrokerAdapterResolver())->resolve($config); + } + + public function testFallsBackToLegacyPublisherSubscriberDefinition(): void + { + $publisher = (new BasicBrokerAdapter())->publisher(); + $subscriber = (new BasicBrokerAdapter())->subscriber(); + $config = $this->config([ + 'publisher' => static fn () => $publisher, + 'subscriber' => static fn () => $subscriber, + ]); + + $adapter = (new BrokerAdapterResolver(new JsonEventSerializer(), new EventFactory()))->resolve($config); + + $this->assertInstanceOf(BrokerAdapterInterface::class, $adapter); + $this->assertSame($publisher, $adapter->publisher()); + } + + /** + * @param array $definition + */ + private function config(array $definition): Sse + { + $config = new Sse(); + $config->broker = 'custom'; + $config->brokers['custom'] = $definition; + + return $config; + } +} diff --git a/tests/Factory/LegacyBrokerAdapterFactoryTest.php b/tests/Factory/LegacyBrokerAdapterFactoryTest.php new file mode 100644 index 0000000..61bb6a8 --- /dev/null +++ b/tests/Factory/LegacyBrokerAdapterFactoryTest.php @@ -0,0 +1,255 @@ +create( + $this->config([ + 'publisher' => static fn (): PublisherInterface => $publisher, + 'subscriber' => static fn (): SubscriberInterface => $subscriber, + ]), + $this->context(), + ); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertInstanceOf(LocalBrokerAdapter::class, $adapter); + $this->assertSame($publisher, $adapter->publisher()); + $this->assertSame($subscriber, $adapter->subscriber()); + $this->assertInstanceOf(LocalSseSubscriptionEndpoint::class, $adapter->subscriptionEndpoint()); + } + + public function testCreatesLocalAdapterFromLegacyInvokableDefinitions(): void + { + $publisher = new RecordingPublisher(); + $subscriber = new RecordingSubscriber(); + + $adapter = (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => new class ($publisher) { + public function __construct( + private readonly PublisherInterface $publisher, + ) { + } + + public function __invoke(): PublisherInterface + { + return $this->publisher; + } + }, + 'subscriber' => new class ($subscriber) { + public function __construct( + private readonly SubscriberInterface $subscriber, + ) { + } + + public function __invoke(): SubscriberInterface + { + return $this->subscriber; + } + }, + ]), + $this->context(), + ); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertSame($publisher, $adapter->publisher()); + $this->assertSame($subscriber, $adapter->subscriber()); + } + + public function testLegacySharedDefinitionReusesSingleBrokerObject(): void + { + RecordingBroker::reset(); + + $adapter = (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => RecordingBroker::class, + 'subscriber' => RecordingBroker::class, + 'shared' => true, + ]), + $this->context(), + ); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertSame($adapter->publisher(), $adapter->subscriber()); + $this->assertSame(1, RecordingBroker::$constructed); + } + + public function testLegacyNonSharedDefinitionCreatesPublisherAndSubscriberSeparately(): void + { + RecordingBroker::reset(); + + $adapter = (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => RecordingBroker::class, + 'subscriber' => RecordingBroker::class, + ]), + $this->context(), + ); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertNotSame($adapter->publisher(), $adapter->subscriber()); + $this->assertSame(2, RecordingBroker::$constructed); + } + + public function testCreatesMercureAdapterFromLegacyTransportDefinition(): void + { + $adapter = (new LegacyBrokerAdapterFactory())->create( + $this->mercureConfig([ + 'publisher' => MercurePublisher::class, + 'transport' => 'mercure', + ]), + $this->context(), + ); + + $this->assertInstanceOf(MercureBrokerAdapter::class, $adapter); + $this->assertInstanceOf(MercurePublisher::class, $adapter->publisher()); + } + + public function testCreatesLegacyRedisPublisherAndSubscriber(): void + { + $adapter = (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => RedisPublisher::class, + 'subscriber' => RedisSubscriber::class, + ]), + $this->context(), + ); + + $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); + $this->assertInstanceOf(RedisPublisher::class, $adapter->publisher()); + $this->assertInstanceOf(RedisSubscriber::class, $adapter->subscriber()); + } + + public function testRejectsNonArrayDefinition(): void + { + $config = new Sse(); + $config->broker = 'legacy'; + (new ReflectionProperty($config, 'brokers'))->setValue($config, ['legacy' => 'invalid']); + + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE broker definition must be an array.'); + + (new LegacyBrokerAdapterFactory())->create($config, $this->context()); + } + + public function testRejectsInvalidPublisherDefinition(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE publisher must implement ' . PublisherInterface::class); + + (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => static fn (): stdClass => new stdClass(), + 'subscriber' => static fn (): SubscriberInterface => new RecordingSubscriber(), + ]), + $this->context(), + ); + } + + public function testRejectsInvalidSubscriberDefinition(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE subscriber must implement ' . SubscriberInterface::class); + + (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => static fn (): PublisherInterface => new RecordingPublisher(), + 'subscriber' => static fn (): stdClass => new stdClass(), + ]), + $this->context(), + ); + } + + public function testRejectsMissingBrokerClass(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The configured SSE broker class "MissingLegacyBroker" does not exist.'); + + (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => 'MissingLegacyBroker', + 'subscriber' => static fn (): SubscriberInterface => new RecordingSubscriber(), + ]), + $this->context(), + ); + } + + public function testRejectsMissingSubscriberDefinition(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('The SSE subscriber broker definition is invalid.'); + + (new LegacyBrokerAdapterFactory())->create( + $this->config([ + 'publisher' => static fn (): PublisherInterface => new RecordingPublisher(), + ]), + $this->context(), + ); + } + + /** + * @param array $definition + */ + private function config(array $definition): Sse + { + $config = new Sse(); + $config->broker = 'legacy'; + $config->brokers['legacy'] = $definition; + + return $config; + } + + /** + * @param array $definition + */ + private function mercureConfig(array $definition): Sse + { + $config = $this->config($definition); + $config->mercure = [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'publisherKey' => 'publisher-test-secret', + 'subscriberKey' => 'subscriber-test-secret', + ]; + + return $config; + } + + private function context(): BrokerBuildContext + { + return new BrokerBuildContext(new JsonEventSerializer(), new EventFactory()); + } +} diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php new file mode 100644 index 0000000..ce388ea --- /dev/null +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -0,0 +1,193 @@ +http(); + $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); + + $result = $endpoint->preflight($request, $response); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame(406, $result->getStatusCode()); + $this->assertStringContainsString('not_acceptable', (string) $result->getBody()); + } + + #[DataProvider('provideLocalEndpointAcceptsEventStreamCompatibleRequests')] + public function testLocalEndpointAcceptsEventStreamCompatibleRequests( + ?string $accept, + bool $requireAcceptHeader, + ): void { + [$request, $response] = $this->http($accept); + $endpoint = new LocalSseSubscriptionEndpoint( + $this->manager(), + $requireAcceptHeader, + ); + + $this->assertNull($endpoint->preflight($request, $response)); + } + + /** + * @return iterable + */ + public static function provideLocalEndpointAcceptsEventStreamCompatibleRequests(): iterable + { + yield 'event stream' => ['text/event-stream', true]; + + yield 'event stream with parameters' => ['text/event-stream; charset=utf-8', true]; + + yield 'wildcard' => ['application/json, */*', true]; + + yield 'accept header disabled' => [null, false]; + } + + public function testLocalEndpointCreatesStreamingResponse(): void + { + [$request, $response] = $this->http('text/event-stream'); + $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); + + $result = $endpoint->respond($request, $response, ['public.news']); + + $this->assertInstanceOf(LegacySseResponse::class, $result); + $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); + + $result->pretend(); + ob_start(); + $result->send(); + $output = ob_get_clean(); + + $this->assertIsString($output); + $this->assertStringContainsString("retry: 3000\n\n", $output); + $this->assertStringContainsString("event: sse.connected\n", $output); + $this->assertStringContainsString("id: connected-id\n", $output); + $this->assertStringContainsString('"channels":["public.news"]', $output); + } + + public function testMercureEndpointReturnsBootstrapPayloadAndCookie(): void + { + [$request, $response] = $this->http(); + $endpoint = new MercureSubscriptionEndpoint($this->mercureConfig()); + + $result = $endpoint->respond($request, $response, ['public.news']); + $body = $result->getBody(); + + $this->assertSame(200, $result->getStatusCode()); + $this->assertStringContainsString('private', $result->getHeaderLine('Cache-Control')); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame( + '; rel="mercure"', + $result->getHeaderLine('Link'), + ); + $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); + $this->assertIsString($body); + + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $this->assertSame('mercure', $decoded['transport']); + $this->assertSame('https://example.test/.well-known/mercure', $decoded['hub']); + $this->assertSame(['urn:example:sse:public.news'], $decoded['topics']); + $this->assertIsInt($decoded['expiresAt']); + + $cookie = $result->getCookie('mercureAuthorization'); + $this->assertInstanceOf(Cookie::class, $cookie); + $this->assertTrue($cookie->isSecure()); + $this->assertTrue($cookie->isHTTPOnly()); + } + + public function testMercureEndpointDeletesCookieWhenSubscriberAuthorizationIsDisabled(): void + { + [$request, $response] = $this->http(); + $config = $this->mercureConfig([ + 'private' => false, + 'authorizeSubscribers' => false, + 'subscriberKey' => null, + ]); + $endpoint = new MercureSubscriptionEndpoint($config); + + $result = $endpoint->respond($request, $response, ['public.news']); + $body = $result->getBody(); + + $this->assertIsString($body); + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + + $this->assertNull($decoded['expiresAt']); + $cookie = $result->getCookie('mercureAuthorization'); + $this->assertInstanceOf(Cookie::class, $cookie); + $this->assertSame('', $cookie->getValue()); + } + + /** + * @return array{RequestInterface, ResponseInterface} + */ + private function http(?string $accept = null): array + { + $request = single_service('request'); + $response = single_service('response'); + + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertInstanceOf(ResponseInterface::class, $response); + + $request->removeHeader('Accept'); + + if ($accept !== null) { + $request->setHeader('Accept', $accept); + } + + return [$request, $response]; + } + + private function manager(): SseConnectionManager + { + return new SseConnectionManager( + new RecordingSubscriber(), + new JsonEventSerializer(), + new EventFactory(new FixedEventIdGenerator('connected-id')), + ); + } + + /** + * @param array $mercure + */ + private function mercureConfig(array $mercure = []): Sse + { + $config = new Sse(); + $config->broker = 'mercure'; + $config->mercure = array_replace_recursive([ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'publisherKey' => 'publisher-test-secret', + 'subscriberKey' => 'subscriber-test-secret', + 'cookie' => [ + 'name' => 'mercureAuthorization', + 'secure' => true, + 'httpOnly' => true, + 'sameSite' => 'Lax', + ], + ], $mercure); + + return $config; + } +} diff --git a/tests/Support/Adapter/BasicBrokerAdapter.php b/tests/Support/Adapter/BasicBrokerAdapter.php new file mode 100644 index 0000000..8224680 --- /dev/null +++ b/tests/Support/Adapter/BasicBrokerAdapter.php @@ -0,0 +1,37 @@ +publisher; + } + + public function subscriber(): SubscriberInterface + { + return $this->subscriber; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } +} diff --git a/tests/Support/Adapter/BasicBrokerAdapterFactory.php b/tests/Support/Adapter/BasicBrokerAdapterFactory.php new file mode 100644 index 0000000..39b52eb --- /dev/null +++ b/tests/Support/Adapter/BasicBrokerAdapterFactory.php @@ -0,0 +1,29 @@ + + */ + public array $channels = []; + + public function respond( + RequestInterface $request, + ResponseInterface $response, + array $channels, + ): ResponseInterface { + $this->channels = $channels; + + return $response->setStatusCode(204); + } +} diff --git a/tests/Support/Adapter/InvalidAdapter.php b/tests/Support/Adapter/InvalidAdapter.php new file mode 100644 index 0000000..b25f099 --- /dev/null +++ b/tests/Support/Adapter/InvalidAdapter.php @@ -0,0 +1,9 @@ + + */ + public array $published = []; + + /** + * @var list + */ + public array $channels = []; + + public function __construct() + { + self::$constructed++; + } + + public static function reset(): void + { + self::$constructed = 0; + } + + public function publish(string $channel, EventInterface $event): void + { + $this->published[] = ['channel' => $channel, 'event' => $event]; + } + + public function subscribe( + array $channels, + callable $onMessage, + ?callable $shouldStop = null, + ?callable $onIdle = null, + ): void { + $this->channels = $channels; + + if ($onIdle !== null) { + $onIdle(); + } + } +} From 9d8bde9c92fee291b9305e185aab0c3db8a58691 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 13:13:24 +0200 Subject: [PATCH 03/12] Refactor broker adapter, factory, and validator classes --- composer.json | 3 +- docs/channels-and-authorization.md | 4 +- docs/configuration.md | 68 +++-- docs/mercure.md | 6 +- docs/module-structure.md | 14 +- src/Authorization/PublicChannelAuthorizer.php | 3 +- .../Config/AbstractBrokerConfigFactory.php | 36 +++ src/Broker/LocalBrokerAdapterFactory.php | 8 +- .../Mercure/MercureBrokerAdapterFactory.php | 2 - .../Mercure}/MercureConfigFactory.php | 28 +- .../Mercure}/MercureSubscriptionEndpoint.php | 13 +- .../Redis/RedisBrokerAdapterFactory.php | 15 +- .../Redis/RedisChannelPattern.php} | 4 +- .../Redis/RedisChannelSelectorValidator.php | 32 +++ .../Redis}/RedisConfigFactory.php | 15 +- src/Broker/Redis/RedisSubscriber.php | 3 +- src/Commands/HealthCheckCommand.php | 10 +- src/Config/Sse.php | 47 +--- .../ChannelSelectorValidatorInterface.php | 10 + ...nnelSelectorValidatorProviderInterface.php | 10 + .../LocalSseSubscriptionEndpoint.php | 14 +- src/Factory/BrokerAdapterResolver.php | 9 +- src/Factory/BrokerFactory.php | 4 +- src/Factory/ConnectionManagerFactory.php | 16 +- src/Factory/HealthCheckerFactory.php | 26 -- src/Factory/LegacyBrokerAdapterFactory.php | 136 ---------- src/Factory/MercureSubscriptionFactory.php | 1 + src/HTTP/ChannelRequestParser.php | 16 +- src/HTTP/SseController.php | 24 +- src/Stream/SseConnectionManager.php | 25 +- src/Stream/SseConnectionOptions.php | 40 +++ src/Support/ChannelNameValidator.php | 15 ++ tests/Broker/LocalBrokerAdapterTest.php | 2 +- .../Mercure/MercureBrokerAdapterTest.php | 4 +- tests/Broker/Mercure/MercureConfigTest.php | 130 +++++++++ tests/Broker/Mercure/MercurePublisherTest.php | 2 +- tests/Broker/Redis/RedisBrokerAdapterTest.php | 20 +- .../Broker/Redis/RedisChannelPatternTest.php | 50 ++++ .../RedisChannelSelectorValidatorTest.php | 57 ++++ tests/Broker/Redis/RedisConfigTest.php | 52 ++++ tests/Config/ServicesTest.php | 31 ++- tests/Config/SseConfigTest.php | 32 +-- tests/Factory/BrokerAdapterResolverTest.php | 17 +- .../LegacyBrokerAdapterFactoryTest.php | 255 ------------------ tests/HTTP/ChannelRequestParserTest.php | 21 +- tests/HTTP/SubscriptionEndpointTest.php | 28 +- tests/Integration/MercureIntegrationTest.php | 2 +- tests/Stream/SseConnectionManagerTest.php | 3 +- tests/Stream/SseConnectionOptionsTest.php | 59 ++++ tests/Support/ChannelNameValidatorTest.php | 29 ++ 50 files changed, 777 insertions(+), 674 deletions(-) create mode 100644 src/Broker/Config/AbstractBrokerConfigFactory.php rename src/{Factory => Broker/Mercure}/MercureConfigFactory.php (75%) rename src/{HTTP => Broker/Mercure}/MercureSubscriptionEndpoint.php (82%) rename src/{Support/ChannelPattern.php => Broker/Redis/RedisChannelPattern.php} (88%) create mode 100644 src/Broker/Redis/RedisChannelSelectorValidator.php rename src/{Factory => Broker/Redis}/RedisConfigFactory.php (76%) create mode 100644 src/Contracts/ChannelSelectorValidatorInterface.php create mode 100644 src/Contracts/ChannelSelectorValidatorProviderInterface.php rename src/{HTTP => Endpoint}/LocalSseSubscriptionEndpoint.php (79%) delete mode 100644 src/Factory/HealthCheckerFactory.php delete mode 100644 src/Factory/LegacyBrokerAdapterFactory.php create mode 100644 src/Stream/SseConnectionOptions.php create mode 100644 src/Support/ChannelNameValidator.php create mode 100644 tests/Broker/Mercure/MercureConfigTest.php create mode 100644 tests/Broker/Redis/RedisChannelPatternTest.php create mode 100644 tests/Broker/Redis/RedisChannelSelectorValidatorTest.php delete mode 100644 tests/Factory/LegacyBrokerAdapterFactoryTest.php create mode 100644 tests/Stream/SseConnectionOptionsTest.php create mode 100644 tests/Support/ChannelNameValidatorTest.php diff --git a/composer.json b/composer.json index 0ee4b13..4810777 100644 --- a/composer.json +++ b/composer.json @@ -51,7 +51,8 @@ }, "autoload-dev": { "psr-4": { - "Tests\\": "tests/" + "Tests\\": "tests/", + "Support\\Tests\\": "tests/_support/" } }, "scripts": { diff --git a/docs/channels-and-authorization.md b/docs/channels-and-authorization.md index 11ac189..e37a18e 100644 --- a/docs/channels-and-authorization.md +++ b/docs/channels-and-authorization.md @@ -203,7 +203,9 @@ one unindexed query per channel. Pattern subscriptions are disabled by default: ```php -public bool $allowPatternSubscriptions = false; +public array $redis = [ + 'allowPatternSubscriptions' => false, +]; ``` Enabling them allows glob-style Redis subscription patterns. A pattern can diff --git a/docs/configuration.md b/docs/configuration.md index e7bbf49..f7783fa 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -158,9 +158,8 @@ final class Sse extends BaseSse | Property | Default | Purpose | |---|---:|---| | `broker` | `redis` | Active key from `brokers`. | -| `brokers` | built-in Redis, Mercure, memory, null definitions | Publisher/subscriber class or factory map. | +| `brokers` | built-in Redis, Mercure, memory, null definitions | Broker adapter or broker adapter factory map. | | `channelPrefix` | `app:sse:` | Prefix added to logical Redis channels. | -| `allowPatternSubscriptions` | `false` | Permit Redis-style pattern requests. | `memory` is useful for isolated tests in one PHP process. It cannot carry a message between separate HTTP requests or workers. `null` is a message sink: @@ -168,10 +167,10 @@ it discards published events but an enabled SSE route still keeps each stream open until disconnect or maximum lifetime. Set `route['enabled'] = false` to disable the HTTP endpoint. -`mercure` has a publisher but no PHP subscriber. Its broker definition uses -`transport = mercure`, so the package route issues subscriber authorization -instead of creating `SseConnectionManager`. See [Mercure Hub](mercure.md) for -the complete configuration. +`mercure` has a publisher but no PHP subscriber. Its broker adapter supplies a +Mercure HTTP subscription endpoint, so the package route issues subscriber +authorization instead of creating a PHP stream. See [Mercure Hub](mercure.md) +for the complete configuration. Do not use an empty shared prefix when several applications publish to the same Redis instance. @@ -180,11 +179,23 @@ Redis Pub/Sub is global across numbered Redis databases. `redis['database']` therefore does not separate SSE traffic; `channelPrefix` is the isolation boundary. -Custom brokers are added by registering a new key in `brokers`: +Custom brokers are added by registering a new key in `brokers`. A broker +definition must provide either: + +- `factory`: `BrokerAdapterFactoryInterface`, callable returning one, or class + name implementing it; +- `adapter`: `BrokerAdapterInterface`, callable returning one, or class name + implementing it. + +The adapter owns publishing and the HTTP subscription endpoint. If the broker +can stream through PHP, implement `SubscriberAwareBrokerAdapterInterface` too. ```php -use App\Sse\CustomPublisher; -use App\Sse\CustomSubscriber; +use App\Sse\CustomBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\NullBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; final class Sse extends BaseSse { @@ -192,26 +203,21 @@ final class Sse extends BaseSse public array $brokers = [ 'redis' => [ - 'publisher' => \Maniaba\CodeIgniterSse\Broker\Redis\RedisPublisher::class, - 'subscriber' => \Maniaba\CodeIgniterSse\Broker\Redis\RedisSubscriber::class, + 'factory' => RedisBrokerAdapterFactory::class, ], 'mercure' => [ - 'publisher' => \Maniaba\CodeIgniterSse\Broker\Mercure\MercurePublisher::class, - 'transport' => 'mercure', + 'factory' => MercureBrokerAdapterFactory::class, ], 'memory' => [ - 'publisher' => \Maniaba\CodeIgniterSse\Broker\InMemoryBroker::class, - 'subscriber' => \Maniaba\CodeIgniterSse\Broker\InMemoryBroker::class, - 'shared' => true, + 'factory' => InMemoryBrokerAdapterFactory::class, + 'shared' => true, ], 'null' => [ - 'publisher' => \Maniaba\CodeIgniterSse\Broker\NullBroker::class, - 'subscriber' => \Maniaba\CodeIgniterSse\Broker\NullBroker::class, - 'shared' => true, + 'factory' => NullBrokerAdapterFactory::class, + 'shared' => true, ], 'custom' => [ - 'publisher' => CustomPublisher::class, - 'subscriber' => CustomSubscriber::class, + 'factory' => CustomBrokerAdapterFactory::class, ], ]; } @@ -221,14 +227,16 @@ When a broker needs application services or constructor arguments, use factory closures: ```php +use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; + public array $brokers = [ - 'redis' => [ - 'publisher' => \Maniaba\CodeIgniterSse\Broker\Redis\RedisPublisher::class, - 'subscriber' => \Maniaba\CodeIgniterSse\Broker\Redis\RedisSubscriber::class, - ], 'custom' => [ - 'publisher' => static fn (): PublisherInterface => service('customSsePublisher'), - 'subscriber' => static fn (): SubscriberInterface => service('customSseSubscriber'), + 'adapter' => static fn ( + Sse $config, + BrokerBuildContext $context, + ): BrokerAdapterInterface => service('customSseBrokerAdapter'), ], ]; ``` @@ -268,8 +276,8 @@ sse.mercure.subscriberKey = replace-with-the-hub-subscriber-key ``` Keep Hub signing keys out of source control. The package validates Mercure -configuration when that broker is selected. The full key reference and -deployment guidance are in [Mercure Hub](mercure.md). +configuration when the Mercure adapter factory builds the broker. The full key +reference and deployment guidance are in [Mercure Hub](mercure.md). ## Redis connection @@ -293,6 +301,7 @@ public array $redis = [ 'maxPayloadBytes' => 1_048_576, 'maxResponseElements' => 1024, 'maxResponseDepth' => 8, + 'allowPatternSubscriptions' => false, 'clientName' => null, 'streamContext' => [], ]; @@ -316,6 +325,7 @@ public array $redis = [ | `maxPayloadBytes` | `1048576` | Maximum serialized event or inbound RESP bulk string size. | | `maxResponseElements` | `1024` | Maximum elements accepted in one RESP array. | | `maxResponseDepth` | `8` | Maximum accepted RESP nesting depth. | +| `allowPatternSubscriptions` | `false` | Permit Redis-style pattern requests. | | `clientName` | `null` | Optional Redis connection name. | | `streamContext` | `[]` | PHP stream context options, usually under `ssl`. | diff --git a/docs/mercure.md b/docs/mercure.md index 6538bd5..bfcd085 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -305,6 +305,6 @@ requires the configured HMAC subscriber key. Applications using an external OAuth/JWKS issuer should replace the authorization controller rather than exposing signing keys to the browser. -The adapter currently maps exact logical channels. Keep -`allowPatternSubscriptions = false`; Redis glob patterns are not accepted by -the Mercure transport. +The adapter currently maps exact logical channels. Redis glob patterns are not +accepted by the Mercure transport; pattern selectors remain a Redis adapter +option. diff --git a/docs/module-structure.md b/docs/module-structure.md index 1c81251..7b00617 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -7,11 +7,13 @@ and browser behavior separate. src/ ├── Authorization/ ├── Broker/ +│ ├── Config/ │ ├── Mercure/ │ └── Redis/ ├── Commands/ ├── Config/ ├── Contracts/ +├── Endpoint/ ├── Event/ ├── Exception/ ├── HTTP/ @@ -43,8 +45,8 @@ Publisher and subscriber connections are separate because Redis subscriptions are blocking. `Broker\Mercure` contains the HTTP publisher, topic mapper, JWT issuer, and -Hub configuration. Mercure has no PHP subscriber because browsers subscribe -directly to the Hub. +Hub configuration and subscription endpoint. Mercure has no PHP subscriber +because browsers subscribe directly to the Hub. `InMemoryBroker` is for tests and one-process examples. `NullBroker` is useful when applications want the API enabled without delivering live events. @@ -52,8 +54,12 @@ when applications want the API enabled without delivering live events. ## HTTP layer `HTTP\SseController` parses the channel request, resolves the current user, -and authorizes every channel. It either starts the Redis-backed PHP stream or -returns Mercure bootstrap data and an HttpOnly subscriber cookie. +authorizes every channel, and delegates the response to the active broker +adapter's subscription endpoint. + +`Endpoint\LocalSseSubscriptionEndpoint` is the generic PHP stream endpoint +used by local subscriber-aware brokers. Broker-specific endpoints, such as +Mercure's bootstrap endpoint, live beside their broker implementation. `HTTP\SseResponseFactory` selects the output implementation at runtime: diff --git a/src/Authorization/PublicChannelAuthorizer.php b/src/Authorization/PublicChannelAuthorizer.php index f1bf2f4..56a0257 100644 --- a/src/Authorization/PublicChannelAuthorizer.php +++ b/src/Authorization/PublicChannelAuthorizer.php @@ -6,7 +6,6 @@ use Maniaba\CodeIgniterSse\Contracts\ChannelAuthorizerInterface; use Maniaba\CodeIgniterSse\Support\Channel; -use Maniaba\CodeIgniterSse\Support\ChannelPattern; /** * Secure default: anonymous access is allowed only to public.* channels. @@ -17,7 +16,7 @@ public function authorize(?object $user, string $channel): bool { $channel = strpbrk($channel, '*?[') === false ? Channel::from($channel)->value() - : (new ChannelPattern($channel))->value(); + : trim($channel); return str_starts_with($channel, 'public.'); } diff --git a/src/Broker/Config/AbstractBrokerConfigFactory.php b/src/Broker/Config/AbstractBrokerConfigFactory.php new file mode 100644 index 0000000..1aba0a6 --- /dev/null +++ b/src/Broker/Config/AbstractBrokerConfigFactory.php @@ -0,0 +1,36 @@ + + */ + protected static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_filter( + $value, + is_string(...), + )); + } + + /** + * @return array + */ + protected static function arrayOption(mixed $value): array + { + return is_array($value) ? $value : []; + } +} diff --git a/src/Broker/LocalBrokerAdapterFactory.php b/src/Broker/LocalBrokerAdapterFactory.php index 1bc5279..9332d78 100644 --- a/src/Broker/LocalBrokerAdapterFactory.php +++ b/src/Broker/LocalBrokerAdapterFactory.php @@ -10,9 +10,10 @@ use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; -use Maniaba\CodeIgniterSse\HTTP\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; +use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; final readonly class LocalBrokerAdapterFactory implements BrokerAdapterFactoryInterface { @@ -39,10 +40,7 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI $broker, $context->serializer, $context->events, - $config->heartbeatInterval, - $config->maxConnectionSeconds, - $config->retryMilliseconds, - $config->emitConnectedEvent, + SseConnectionOptions::fromConfig($config), ); return new LocalBrokerAdapter( diff --git a/src/Broker/Mercure/MercureBrokerAdapterFactory.php b/src/Broker/Mercure/MercureBrokerAdapterFactory.php index 6e140f9..dac012e 100644 --- a/src/Broker/Mercure/MercureBrokerAdapterFactory.php +++ b/src/Broker/Mercure/MercureBrokerAdapterFactory.php @@ -8,8 +8,6 @@ use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; -use Maniaba\CodeIgniterSse\HTTP\MercureSubscriptionEndpoint; final readonly class MercureBrokerAdapterFactory implements BrokerAdapterFactoryInterface { diff --git a/src/Factory/MercureConfigFactory.php b/src/Broker/Mercure/MercureConfigFactory.php similarity index 75% rename from src/Factory/MercureConfigFactory.php rename to src/Broker/Mercure/MercureConfigFactory.php index 5d558a6..43b0fc3 100644 --- a/src/Factory/MercureConfigFactory.php +++ b/src/Broker/Mercure/MercureConfigFactory.php @@ -2,17 +2,17 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Factory; +namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfig; +use Maniaba\CodeIgniterSse\Broker\Config\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; -final class MercureConfigFactory +final class MercureConfigFactory extends AbstractBrokerConfigFactory { public function create(Sse $config): MercureConfig { $mercure = $config->mercure(); - $cookie = is_array($mercure['cookie'] ?? null) ? $mercure['cookie'] : []; + $cookie = self::arrayOption($mercure['cookie'] ?? null); return new MercureConfig( hubUrl: (string) $mercure['hubUrl'], @@ -43,24 +43,4 @@ public function create(Sse $config): MercureConfig cookieSameSite: (string) ($cookie['sameSite'] ?? 'Lax'), ); } - - private static function nullableString(mixed $value): ?string - { - return is_string($value) && $value !== '' ? $value : null; - } - - /** - * @return list - */ - private static function stringList(mixed $value): array - { - if (! is_array($value)) { - return []; - } - - return array_values(array_filter( - $value, - is_string(...), - )); - } } diff --git a/src/HTTP/MercureSubscriptionEndpoint.php b/src/Broker/Mercure/MercureSubscriptionEndpoint.php similarity index 82% rename from src/HTTP/MercureSubscriptionEndpoint.php rename to src/Broker/Mercure/MercureSubscriptionEndpoint.php index cfdc597..a35cce3 100644 --- a/src/HTTP/MercureSubscriptionEndpoint.php +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -2,16 +2,18 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\HTTP; +namespace Maniaba\CodeIgniterSse\Broker\Mercure; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; +use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; -final readonly class MercureSubscriptionEndpoint implements SubscriptionEndpointInterface +final readonly class MercureSubscriptionEndpoint implements SubscriptionEndpointInterface, ChannelSelectorValidatorProviderInterface { public function __construct( private Sse $config, @@ -20,6 +22,11 @@ public function __construct( ) { } + public function channelSelectorValidator(): ChannelSelectorValidatorInterface + { + return new ChannelNameValidator(); + } + public function respond( RequestInterface $request, ResponseInterface $response, diff --git a/src/Broker/Redis/RedisBrokerAdapterFactory.php b/src/Broker/Redis/RedisBrokerAdapterFactory.php index 1610367..4b2b7b0 100644 --- a/src/Broker/Redis/RedisBrokerAdapterFactory.php +++ b/src/Broker/Redis/RedisBrokerAdapterFactory.php @@ -7,10 +7,10 @@ use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; -use Maniaba\CodeIgniterSse\Factory\RedisConfigFactory; -use Maniaba\CodeIgniterSse\HTTP\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; +use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; final readonly class RedisBrokerAdapterFactory implements BrokerAdapterFactoryInterface { @@ -29,17 +29,18 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI $subscriber, $context->serializer, $context->events, - $config->heartbeatInterval, - $config->maxConnectionSeconds, - $config->retryMilliseconds, - $config->emitConnectedEvent, + SseConnectionOptions::fromConfig($config), ); return new RedisBrokerAdapter( $redis, $publisher, $subscriber, - new LocalSseSubscriptionEndpoint($manager, $config->requireAcceptHeader), + new LocalSseSubscriptionEndpoint( + $manager, + $config->requireAcceptHeader, + channelSelectorValidator: new RedisChannelSelectorValidator($redis), + ), new RedisHealthChecker(new RedisConnectionFactory($redis)), ); } diff --git a/src/Support/ChannelPattern.php b/src/Broker/Redis/RedisChannelPattern.php similarity index 88% rename from src/Support/ChannelPattern.php rename to src/Broker/Redis/RedisChannelPattern.php index 0782d62..f61999f 100644 --- a/src/Support/ChannelPattern.php +++ b/src/Broker/Redis/RedisChannelPattern.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Support; +namespace Maniaba\CodeIgniterSse\Broker\Redis; use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use Stringable; -final readonly class ChannelPattern implements Stringable +final readonly class RedisChannelPattern implements Stringable { private string $pattern; diff --git a/src/Broker/Redis/RedisChannelSelectorValidator.php b/src/Broker/Redis/RedisChannelSelectorValidator.php new file mode 100644 index 0000000..d962da2 --- /dev/null +++ b/src/Broker/Redis/RedisChannelSelectorValidator.php @@ -0,0 +1,32 @@ +config->allowPatternSubscriptions) { + throw new InvalidChannelException('Redis pattern subscriptions are disabled.'); + } + + new RedisChannelPattern($selector); + } +} diff --git a/src/Factory/RedisConfigFactory.php b/src/Broker/Redis/RedisConfigFactory.php similarity index 76% rename from src/Factory/RedisConfigFactory.php rename to src/Broker/Redis/RedisConfigFactory.php index 7214113..cc14b6a 100644 --- a/src/Factory/RedisConfigFactory.php +++ b/src/Broker/Redis/RedisConfigFactory.php @@ -2,12 +2,12 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Factory; +namespace Maniaba\CodeIgniterSse\Broker\Redis; -use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfig; +use Maniaba\CodeIgniterSse\Broker\Config\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; -final class RedisConfigFactory +final class RedisConfigFactory extends AbstractBrokerConfigFactory { public function create(Sse $config): RedisConfig { @@ -29,16 +29,11 @@ public function create(Sse $config): RedisConfig maxPayloadBytes: (int) $redis['maxPayloadBytes'], maxResponseElements: (int) $redis['maxResponseElements'], maxResponseDepth: (int) $redis['maxResponseDepth'], - allowPatternSubscriptions: $config->allowPatternSubscriptions, + allowPatternSubscriptions: (bool) $redis['allowPatternSubscriptions'], username: self::nullableString($redis['username'] ?? null), scheme: (string) $redis['scheme'], - streamContext: is_array($redis['streamContext']) ? $redis['streamContext'] : [], + streamContext: self::arrayOption($redis['streamContext'] ?? null), clientName: self::nullableString($redis['clientName'] ?? null), ); } - - private static function nullableString(mixed $value): ?string - { - return is_string($value) ? $value : null; - } } diff --git a/src/Broker/Redis/RedisSubscriber.php b/src/Broker/Redis/RedisSubscriber.php index 23f7d80..b549d48 100644 --- a/src/Broker/Redis/RedisSubscriber.php +++ b/src/Broker/Redis/RedisSubscriber.php @@ -14,7 +14,6 @@ use Maniaba\CodeIgniterSse\Event\BrokerMessage; use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use Maniaba\CodeIgniterSse\Support\Channel; -use Maniaba\CodeIgniterSse\Support\ChannelPattern; final class RedisSubscriber implements SubscriberInterface { @@ -146,7 +145,7 @@ private function prepareSubscriptions(array $channels): array } if ($this->isPattern($channel)) { - $channel = (new ChannelPattern($channel))->value(); + $channel = (new RedisChannelPattern($channel))->value(); $patterns[$this->config->channelPrefix . $channel] = true; continue; diff --git a/src/Commands/HealthCheckCommand.php b/src/Commands/HealthCheckCommand.php index 1e92886..8745e09 100644 --- a/src/Commands/HealthCheckCommand.php +++ b/src/Commands/HealthCheckCommand.php @@ -8,8 +8,8 @@ use CodeIgniter\CLI\CLI; use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\HealthCheckableInterface; -use Maniaba\CodeIgniterSse\Factory\BrokerFactory; final class HealthCheckCommand extends BaseCommand { @@ -24,7 +24,13 @@ final class HealthCheckCommand extends BaseCommand public function run(array $params): int { $config = Sse::discover(); - $adapter = (new BrokerFactory())->adapter($config); + $adapter = service('sseBrokerAdapter', $config, false); + + if (! $adapter instanceof BrokerAdapterInterface) { + CLI::error('The sseBrokerAdapter service must implement ' . BrokerAdapterInterface::class . '.'); + + return EXIT_ERROR; + } if (! $adapter instanceof HealthCheckableInterface) { CLI::write( diff --git a/src/Config/Sse.php b/src/Config/Sse.php index 313cdee..d0a1a8d 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -16,10 +16,7 @@ use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\ChannelAuthorizerInterface; -use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; -use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; use Maniaba\CodeIgniterSse\Contracts\UserResolverInterface; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; use Maniaba\CodeIgniterSse\HTTP\SseController; class Sse extends BaseConfig @@ -44,6 +41,7 @@ class Sse extends BaseConfig 'maxPayloadBytes' => 1_048_576, 'maxResponseElements' => 1024, 'maxResponseDepth' => 8, + 'allowPatternSubscriptions' => false, 'clientName' => null, 'streamContext' => [], ]; @@ -121,10 +119,7 @@ class Sse extends BaseConfig /** * @var array, - * adapter?: BrokerAdapterInterface|callable(self): BrokerAdapterInterface|class-string, - * publisher?: callable(self): PublisherInterface|class-string, - * subscriber?: callable(self): SubscriberInterface|class-string, - * transport?: 'mercure'|'php', + * adapter?: BrokerAdapterInterface|callable(self, mixed): BrokerAdapterInterface|class-string, * shared?: bool * }> */ @@ -133,8 +128,7 @@ class Sse extends BaseConfig 'factory' => RedisBrokerAdapterFactory::class, ], 'mercure' => [ - 'factory' => MercureBrokerAdapterFactory::class, - 'transport' => 'mercure', + 'factory' => MercureBrokerAdapterFactory::class, ], 'memory' => [ 'factory' => InMemoryBrokerAdapterFactory::class, @@ -146,14 +140,13 @@ class Sse extends BaseConfig ], ]; - public string $channelPrefix = 'app:sse:'; - public int $retryMilliseconds = 3000; - public int $heartbeatInterval = 15; - public int $maxConnectionSeconds = 300; - public int $maxChannelsPerConnection = 20; - public bool $allowPatternSubscriptions = false; - public bool $emitConnectedEvent = true; - public bool $requireAcceptHeader = true; + public string $channelPrefix = 'app:sse:'; + public int $retryMilliseconds = 3000; + public int $heartbeatInterval = 15; + public int $maxConnectionSeconds = 300; + public int $maxChannelsPerConnection = 20; + public bool $emitConnectedEvent = true; + public bool $requireAcceptHeader = true; /** * CodeIgniter Debug Toolbar publisher tracing. @@ -275,16 +268,6 @@ public function validate(): void 'SSE toolbar maxEvents must be between 1 and 1000.', ); } - - if ($this->streamTransport() === 'mercure') { - if ($this->allowPatternSubscriptions) { - throw new InvalidArgumentException( - 'Pattern subscriptions are not supported by the Mercure adapter.', - ); - } - - (new MercureConfigFactory())->create($this); - } } /** @@ -303,16 +286,6 @@ public function mercure(): array return array_replace_recursive(self::DEFAULT_MERCURE, $this->mercure); } - /** - * @return 'mercure'|'php' - */ - public function streamTransport(): string - { - $transport = $this->brokers[$this->broker]['transport'] ?? 'php'; - - return $transport === 'mercure' ? 'mercure' : 'php'; - } - /** * @return array{ * enabled: bool, diff --git a/src/Contracts/ChannelSelectorValidatorInterface.php b/src/Contracts/ChannelSelectorValidatorInterface.php new file mode 100644 index 0000000..bb1580c --- /dev/null +++ b/src/Contracts/ChannelSelectorValidatorInterface.php @@ -0,0 +1,10 @@ +channelSelectorValidator ?? new ChannelNameValidator(); + } + public function preflight(RequestInterface $request, ResponseInterface $response): ?ResponseInterface { if (! $this->requireAcceptHeader || $this->acceptsEventStream($request)) { diff --git a/src/Factory/BrokerAdapterResolver.php b/src/Factory/BrokerAdapterResolver.php index 78c8e94..0850e6c 100644 --- a/src/Factory/BrokerAdapterResolver.php +++ b/src/Factory/BrokerAdapterResolver.php @@ -23,8 +23,6 @@ final class BrokerAdapterResolver public function __construct( private readonly ?SerializerInterface $serializer = null, private readonly ?EventFactory $events = null, - private readonly ?RedisConfigFactory $redisConfigs = null, - private readonly ?MercureConfigFactory $mercureConfigs = null, ) { } @@ -74,10 +72,9 @@ private function make(Sse $config, array $definition): BrokerAdapterInterface ->create($config, $this->context()); } - return (new LegacyBrokerAdapterFactory( - $this->redisConfigs, - $this->mercureConfigs, - ))->create($config, $this->context()); + throw new LogicException( + 'The configured SSE broker definition must define either "factory" or "adapter".', + ); } private function makeAdapter(Sse $config, mixed $definition): BrokerAdapterInterface diff --git a/src/Factory/BrokerFactory.php b/src/Factory/BrokerFactory.php index e5d6432..2270c23 100644 --- a/src/Factory/BrokerFactory.php +++ b/src/Factory/BrokerFactory.php @@ -17,7 +17,7 @@ final class BrokerFactory { - public function __construct(private readonly ?SerializerInterface $serializer = null, private readonly ?RedisConfigFactory $redisConfigs = null, private readonly ?MercureConfigFactory $mercureConfigs = null, private readonly ?bool $enableToolbarTracing = null, private readonly ?EventFactory $events = null, private ?BrokerAdapterResolver $resolver = null) + public function __construct(private readonly ?SerializerInterface $serializer = null, private readonly ?bool $enableToolbarTracing = null, private readonly ?EventFactory $events = null, private ?BrokerAdapterResolver $resolver = null) { } @@ -58,8 +58,6 @@ private function resolver(): BrokerAdapterResolver $this->resolver = new BrokerAdapterResolver( $this->serializer, $this->events, - $this->redisConfigs, - $this->mercureConfigs, ); } diff --git a/src/Factory/ConnectionManagerFactory.php b/src/Factory/ConnectionManagerFactory.php index e919594..fd79f31 100644 --- a/src/Factory/ConnectionManagerFactory.php +++ b/src/Factory/ConnectionManagerFactory.php @@ -4,25 +4,31 @@ namespace Maniaba\CodeIgniterSse\Factory; +use LogicException; +use Maniaba\CodeIgniterSse\Config\Services as SseServices; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\SubscriberAwareBrokerAdapterInterface; use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; +use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; final class ConnectionManagerFactory { public function create(Sse $config): SseConnectionManager { $serializer = new JsonEventSerializer(); + $adapter = SseServices::sseBrokerAdapter($config, false); + + if (! $adapter instanceof SubscriberAwareBrokerAdapterInterface) { + throw new LogicException('The configured SSE broker does not provide a PHP subscriber.'); + } return new SseConnectionManager( - (new BrokerFactory($serializer))->subscriber($config), + $adapter->subscriber(), $serializer, new EventFactory(), - $config->heartbeatInterval, - $config->maxConnectionSeconds, - $config->retryMilliseconds, - $config->emitConnectedEvent, + SseConnectionOptions::fromConfig($config), ); } } diff --git a/src/Factory/HealthCheckerFactory.php b/src/Factory/HealthCheckerFactory.php deleted file mode 100644 index 32e833f..0000000 --- a/src/Factory/HealthCheckerFactory.php +++ /dev/null @@ -1,26 +0,0 @@ -redisConfigs ?? new RedisConfigFactory())->create($config), - ), - ); - } -} diff --git a/src/Factory/LegacyBrokerAdapterFactory.php b/src/Factory/LegacyBrokerAdapterFactory.php deleted file mode 100644 index 6813f4a..0000000 --- a/src/Factory/LegacyBrokerAdapterFactory.php +++ /dev/null @@ -1,136 +0,0 @@ -brokers[$config->broker] ?? null; - - if (! is_array($definition)) { - throw new LogicException('The configured SSE broker definition must be an array.'); - } - - $publisher = $this->make($config, $context, $definition['publisher'] ?? null, 'publisher'); - - if (! $publisher instanceof PublisherInterface) { - throw new LogicException('The configured SSE publisher must implement ' . PublisherInterface::class . '.'); - } - - if (($definition['transport'] ?? null) === 'mercure') { - $configs = $this->mercureConfigs ?? new MercureConfigFactory(); - - return new MercureBrokerAdapter( - $configs->create($config), - $publisher, - new MercureSubscriptionEndpoint($config, configs: $configs), - ); - } - - $subscriberDefinition = $definition['subscriber'] ?? null; - $subscriber = ($definition['shared'] ?? false) === true - && ($definition['publisher'] ?? null) === $subscriberDefinition - && $publisher instanceof SubscriberInterface - ? $publisher - : $this->make($config, $context, $subscriberDefinition, 'subscriber'); - - if (! $subscriber instanceof SubscriberInterface) { - throw new LogicException('The configured SSE subscriber must implement ' . SubscriberInterface::class . '.'); - } - - $manager = new SseConnectionManager( - $subscriber, - $context->serializer, - $context->events, - $config->heartbeatInterval, - $config->maxConnectionSeconds, - $config->retryMilliseconds, - $config->emitConnectedEvent, - ); - - return new LocalBrokerAdapter( - $publisher, - $subscriber, - new LocalSseSubscriptionEndpoint($manager, $config->requireAcceptHeader), - ); - } - - private function make(Sse $config, BrokerBuildContext $context, mixed $definition, string $role): object - { - if ($definition instanceof Closure) { - return $definition($config, $context); - } - - if (is_callable($definition) && ! is_string($definition)) { - return $definition($config, $context); - } - - if (is_string($definition)) { - return $this->makeClass($config, $context, $definition); - } - - throw new LogicException(sprintf('The SSE %s broker definition is invalid.', $role)); - } - - private function makeClass(Sse $config, BrokerBuildContext $context, string $class): object - { - if (! class_exists($class)) { - throw new LogicException(sprintf('The configured SSE broker class "%s" does not exist.', $class)); - } - - if (is_a($class, RedisPublisher::class, true)) { - $redis = ($this->redisConfigs ?? new RedisConfigFactory())->create($config); - - return new $class( - $redis, - $context->serializer, - new RedisConnectionFactory($redis), - ); - } - - if (is_a($class, RedisSubscriber::class, true)) { - $redis = ($this->redisConfigs ?? new RedisConfigFactory())->create($config); - - return new $class( - $redis, - $context->serializer, - new RedisConnectionFactory($redis), - ); - } - - if (is_a($class, MercurePublisher::class, true)) { - return new $class( - ($this->mercureConfigs ?? new MercureConfigFactory())->create($config), - $context->serializer, - ); - } - - return new $class(); - } -} diff --git a/src/Factory/MercureSubscriptionFactory.php b/src/Factory/MercureSubscriptionFactory.php index 29e75be..3a35950 100644 --- a/src/Factory/MercureSubscriptionFactory.php +++ b/src/Factory/MercureSubscriptionFactory.php @@ -4,6 +4,7 @@ namespace Maniaba\CodeIgniterSse\Factory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfigFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureJwtFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureSubscription; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureTopicMapper; diff --git a/src/HTTP/ChannelRequestParser.php b/src/HTTP/ChannelRequestParser.php index 8a26327..f8f1073 100644 --- a/src/HTTP/ChannelRequestParser.php +++ b/src/HTTP/ChannelRequestParser.php @@ -4,15 +4,15 @@ namespace Maniaba\CodeIgniterSse\HTTP; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; use Maniaba\CodeIgniterSse\Exception\InvalidChannelRequestException; -use Maniaba\CodeIgniterSse\Support\Channel; -use Maniaba\CodeIgniterSse\Support\ChannelPattern; +use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; final readonly class ChannelRequestParser { public function __construct( private int $maximumChannels = 20, - private bool $allowPatterns = false, + private ?ChannelSelectorValidatorInterface $validator = null, ) { if ($maximumChannels < 1) { throw new InvalidChannelRequestException('At least one requested channel must be allowed.'); @@ -59,14 +59,10 @@ public function parse(array|string|null $input): array ); } - foreach ($parts as $part) { - if ($this->allowPatterns && strpbrk($part, '*?[') !== false) { - new ChannelPattern($part); - - continue; - } + $validator = $this->validator ?? new ChannelNameValidator(); - Channel::from($part); + foreach ($parts as $part) { + $validator->assertValid($part); } return $parts; diff --git a/src/HTTP/SseController.php b/src/HTTP/SseController.php index 5032dd2..05de891 100644 --- a/src/HTTP/SseController.php +++ b/src/HTTP/SseController.php @@ -6,10 +6,13 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\RESTful\ResourceController; +use Maniaba\CodeIgniterSse\Config\Services as SseServices; use Maniaba\CodeIgniterSse\Config\Sse as SseConfig; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use Maniaba\CodeIgniterSse\Exception\InvalidChannelRequestException; use Maniaba\CodeIgniterSse\Exception\InvalidOriginException; @@ -17,7 +20,6 @@ use Maniaba\CodeIgniterSse\Factory\AuthorizationFactory; use Maniaba\CodeIgniterSse\Factory\BrokerFactory; use Maniaba\CodeIgniterSse\Factory\ConnectionManagerFactory; -use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; final class SseController extends ResourceController @@ -27,7 +29,6 @@ public function __construct( private readonly ?SseResponseFactory $responseFactory = null, private readonly ?AuthorizationFactory $authorizations = null, private readonly ?ConnectionManagerFactory $connectionManagers = null, - private readonly ?MercureSubscriptionFactory $mercureSubscriptions = null, private readonly ?SseConfig $config = null, private readonly ?BrokerFactory $brokers = null, ) { @@ -56,7 +57,7 @@ public function stream(): ResponseInterface } try { - $channels = $this->authorizeChannels($config); + $channels = $this->authorizeChannels($config, $endpoint); } catch (InvalidChannelException|InvalidChannelRequestException $exception) { return $cors->apply( $this->error(400, 'invalid_channels', $exception->getMessage()), @@ -78,11 +79,14 @@ public function stream(): ResponseInterface /** * @return list */ - private function authorizeChannels(SseConfig $config): array + private function authorizeChannels(SseConfig $config, SubscriptionEndpointInterface $endpoint): array { + $validator = $endpoint instanceof ChannelSelectorValidatorProviderInterface + ? $endpoint->channelSelectorValidator() + : null; $channels = (new ChannelRequestParser( $config->maxChannelsPerConnection, - $config->allowPatternSubscriptions, + $validator, ))->parse($this->request->getGet('channels')); $authorizations = $this->authorizations ?? new AuthorizationFactory(); @@ -110,10 +114,6 @@ private function subscriptionEndpoint(SseConfig $config): SubscriptionEndpointIn ); } - if ($this->mercureSubscriptions !== null && $config->streamTransport() === 'mercure') { - return new MercureSubscriptionEndpoint($config, $this->mercureSubscriptions); - } - if ($this->config === null && $this->brokers === null) { $adapter = service('sseBrokerAdapter'); @@ -122,7 +122,11 @@ private function subscriptionEndpoint(SseConfig $config): SubscriptionEndpointIn } } - return ($this->brokers ?? new BrokerFactory())->subscriptionEndpoint($config); + if ($this->brokers !== null) { + return $this->brokers->subscriptionEndpoint($config); + } + + return SseServices::sseBrokerAdapter($config, false)->subscriptionEndpoint(); } private function error(int $status, string $code, string $message): ResponseInterface diff --git a/src/Stream/SseConnectionManager.php b/src/Stream/SseConnectionManager.php index 1edca2d..6280dc0 100644 --- a/src/Stream/SseConnectionManager.php +++ b/src/Stream/SseConnectionManager.php @@ -4,7 +4,6 @@ namespace Maniaba\CodeIgniterSse\Stream; -use InvalidArgumentException; use Maniaba\CodeIgniterSse\Contracts\SerializerInterface; use Maniaba\CodeIgniterSse\Contracts\SseOutputInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; @@ -18,22 +17,8 @@ public function __construct( private SubscriberInterface $subscriber, private SerializerInterface $serializer, private EventFactory $events, - private int $heartbeatInterval = 15, - private int $maximumConnectionSeconds = 300, - private int $retryMilliseconds = 3000, - private bool $emitConnectedEvent = true, + private SseConnectionOptions $options = new SseConnectionOptions(), ) { - if ($heartbeatInterval < 1) { - throw new InvalidArgumentException('Heartbeat interval must be at least one second.'); - } - - if ($maximumConnectionSeconds < 1) { - throw new InvalidArgumentException('Maximum connection lifetime must be at least one second.'); - } - - if ($retryMilliseconds < 0) { - throw new InvalidArgumentException('Retry delay must not be negative.'); - } } /** @@ -45,13 +30,13 @@ public function stream(SseOutputInterface $output, array $channels): void $lastHeartbeat = $startedAt; $state = new SseConnectionState(); - $state->stopWhen(! $output->retry($this->retryMilliseconds)); + $state->stopWhen(! $output->retry($this->options->retryMilliseconds)); if ($state->isStopped()) { return; } - if ($this->emitConnectedEvent) { + if ($this->options->emitConnectedEvent) { $connected = new BrokerMessage( 'sse.system', $this->events->create('sse.connected', ['channels' => $channels]), @@ -89,7 +74,7 @@ public function stream(SseOutputInterface $output, array $channels): void )); }, shouldStop: fn (): bool => ! $output->isClientConnected() - || microtime(true) - $startedAt >= $this->maximumConnectionSeconds + || microtime(true) - $startedAt >= $this->options->maximumConnectionSeconds || $state->isStopped(), onIdle: function () use ($output, &$lastHeartbeat, $state): void { if ($state->isStopped()) { @@ -98,7 +83,7 @@ public function stream(SseOutputInterface $output, array $channels): void $now = microtime(true); - if ($now - $lastHeartbeat < $this->heartbeatInterval) { + if ($now - $lastHeartbeat < $this->options->heartbeatInterval) { return; } diff --git a/src/Stream/SseConnectionOptions.php b/src/Stream/SseConnectionOptions.php new file mode 100644 index 0000000..e6a8e3a --- /dev/null +++ b/src/Stream/SseConnectionOptions.php @@ -0,0 +1,40 @@ +heartbeatInterval, + $config->maxConnectionSeconds, + $config->retryMilliseconds, + $config->emitConnectedEvent, + ); + } +} diff --git a/src/Support/ChannelNameValidator.php b/src/Support/ChannelNameValidator.php new file mode 100644 index 0000000..8beb586 --- /dev/null +++ b/src/Support/ChannelNameValidator.php @@ -0,0 +1,15 @@ +retryMilliseconds = 1250; + $config->mercure = [ + 'hubUrl' => 'http://mercure/.well-known/mercure', + 'publicHubUrl' => 'https://example.test/.well-known/mercure', + 'topicPrefix' => 'urn:example:sse:', + 'private' => false, + 'authorizeSubscribers' => false, + 'publisherJwt' => 'static-publisher-token', + 'publisherKey' => null, + 'subscriberKey' => null, + 'publisherAlgorithm' => 'hs384', + 'subscriberAlgorithm' => 'hs512', + 'publisherTokenTtl' => 90, + 'subscriberTokenTtl' => 600, + 'publisherTopicSelectors' => ['*', 'users.*', 17], + 'connectTimeout' => 1.5, + 'timeout' => 4.5, + 'verifyTls' => '/etc/ssl/certs/ca.pem', + 'maxPayloadBytes' => 4096, + 'cookie' => [ + 'name' => 'mercureAuth', + 'domain' => 'example.test', + 'path' => '/mercure', + 'secure' => false, + 'httpOnly' => false, + 'sameSite' => 'Lax', + ], + ]; + + $mercure = (new MercureConfigFactory())->create($config); + + $this->assertSame('http://mercure/.well-known/mercure', $mercure->hubUrl); + $this->assertSame('https://example.test/.well-known/mercure', $mercure->publicHubUrl); + $this->assertSame('urn:example:sse:', $mercure->topicPrefix); + $this->assertFalse($mercure->privateUpdates); + $this->assertFalse($mercure->authorizeSubscribers); + $this->assertSame('static-publisher-token', $mercure->publisherJwt); + $this->assertNull($mercure->publisherKey); + $this->assertNull($mercure->subscriberKey); + $this->assertSame('HS384', $mercure->publisherAlgorithm); + $this->assertSame('HS512', $mercure->subscriberAlgorithm); + $this->assertSame(90, $mercure->publisherTokenTtl); + $this->assertSame(600, $mercure->subscriberTokenTtl); + $this->assertSame(['*', 'users.*'], $mercure->publisherTopicSelectors); + $this->assertSame(1.5, $mercure->connectTimeout); + $this->assertSame(4.5, $mercure->timeout); + $this->assertSame('/etc/ssl/certs/ca.pem', $mercure->verifyTls); + $this->assertSame(4096, $mercure->maxPayloadBytes); + $this->assertSame(1250, $mercure->retryMilliseconds); + $this->assertSame('mercureAuth', $mercure->cookieName); + $this->assertSame('example.test', $mercure->cookieDomain); + $this->assertSame('/mercure', $mercure->cookiePath); + $this->assertFalse($mercure->cookieSecure); + $this->assertFalse($mercure->cookieHttpOnly); + $this->assertSame('Lax', $mercure->cookieSameSite); + } + + #[DataProvider('provideRejectsInvalidMercureConfig')] + public function testRejectsInvalidMercureConfig(callable $configure): void + { + $config = new Sse(); + $configure($config); + + $this->expectException(MercureConfigurationException::class); + + (new MercureConfigFactory())->create($config); + } + + /** + * @return iterable + */ + public static function provideRejectsInvalidMercureConfig(): iterable + { + yield 'missing publisher credentials' => [ + static function (Sse $config): void { + $config->mercure = [ + 'subscriberKey' => 'subscriber-test-secret', + ]; + }, + ]; + + yield 'missing subscriber key' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherKey' => 'publisher-test-secret', + ]; + }, + ]; + + yield 'private updates without subscriber authorization' => [ + static function (Sse $config): void { + $config->mercure = [ + 'private' => true, + 'authorizeSubscribers' => false, + 'publisherKey' => 'publisher-test-secret', + ]; + }, + ]; + + yield 'publisher selectors must be a list' => [ + static function (Sse $config): void { + $config->mercure = [ + 'publisherKey' => 'publisher-test-secret', + 'subscriberKey' => 'subscriber-test-secret', + 'publisherTopicSelectors' => '*', + ]; + }, + ]; + } +} diff --git a/tests/Broker/Mercure/MercurePublisherTest.php b/tests/Broker/Mercure/MercurePublisherTest.php index 97b6594..cc39971 100644 --- a/tests/Broker/Mercure/MercurePublisherTest.php +++ b/tests/Broker/Mercure/MercurePublisherTest.php @@ -5,13 +5,13 @@ namespace Tests\Broker\Mercure; use Maniaba\CodeIgniterSse\Broker\Mercure\Exception\MercurePublishException; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfigFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureHttpClientInterface; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureHttpResponse; use Maniaba\CodeIgniterSse\Broker\Mercure\MercurePublisher; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Event\SseEvent; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Broker/Redis/RedisBrokerAdapterTest.php b/tests/Broker/Redis/RedisBrokerAdapterTest.php index c649c19..8804a05 100644 --- a/tests/Broker/Redis/RedisBrokerAdapterTest.php +++ b/tests/Broker/Redis/RedisBrokerAdapterTest.php @@ -8,15 +8,16 @@ use Maniaba\CodeIgniterSse\Broker\Redis\Exception\RedisConnectionException; use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapter; use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisChannelSelectorValidator; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfigFactory; use Maniaba\CodeIgniterSse\Broker\Redis\RedisHealthChecker; use Maniaba\CodeIgniterSse\Broker\Redis\RedisPublisher; use Maniaba\CodeIgniterSse\Broker\Redis\RedisSubscriber; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; -use Maniaba\CodeIgniterSse\Factory\RedisConfigFactory; -use Maniaba\CodeIgniterSse\HTTP\LocalSseSubscriptionEndpoint; use PHPUnit\Framework\TestCase; use Tests\Broker\Redis\Fixtures\FakeRedisConnection; use Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; @@ -97,8 +98,11 @@ public function testHealthCheckReportsConnectionFailure(): void public function testFactoryCreatesRedisAdapterWithoutOpeningAConnection(): void { - $config = new Sse(); - $config->redis = ['host' => 'redis.internal']; + $config = new Sse(); + $config->redis = [ + 'host' => 'redis.internal', + 'allowPatternSubscriptions' => true, + ]; $config->requireAcceptHeader = false; $config->emitConnectedEvent = false; $config->retryMilliseconds = 1500; @@ -113,6 +117,12 @@ public function testFactoryCreatesRedisAdapterWithoutOpeningAConnection(): void $this->assertInstanceOf(RedisBrokerAdapter::class, $adapter); $this->assertInstanceOf(RedisPublisher::class, $adapter->publisher()); $this->assertInstanceOf(RedisSubscriber::class, $adapter->subscriber()); - $this->assertInstanceOf(LocalSseSubscriptionEndpoint::class, $adapter->subscriptionEndpoint()); + $endpoint = $adapter->subscriptionEndpoint(); + + $this->assertInstanceOf(LocalSseSubscriptionEndpoint::class, $endpoint); + $this->assertInstanceOf( + RedisChannelSelectorValidator::class, + $endpoint->channelSelectorValidator(), + ); } } diff --git a/tests/Broker/Redis/RedisChannelPatternTest.php b/tests/Broker/Redis/RedisChannelPatternTest.php new file mode 100644 index 0000000..927c14a --- /dev/null +++ b/tests/Broker/Redis/RedisChannelPatternTest.php @@ -0,0 +1,50 @@ +assertSame('public.*', $pattern->value()); + $this->assertSame('public.*', (string) $pattern); + } + + #[DataProvider('provideRejectsInvalidPattern')] + public function testRejectsInvalidPattern(string $pattern): void + { + $this->expectException(InvalidChannelException::class); + + new RedisChannelPattern($pattern); + } + + /** + * @return iterable + */ + public static function provideRejectsInvalidPattern(): iterable + { + yield 'empty' => ['']; + + yield 'too long' => [str_repeat('a', 201)]; + + yield 'double dot' => ['public..*']; + + yield 'leading dot' => ['.public.*']; + + yield 'trailing dot' => ['public.*.']; + + yield 'invalid character' => ['public.{news}']; + } +} diff --git a/tests/Broker/Redis/RedisChannelSelectorValidatorTest.php b/tests/Broker/Redis/RedisChannelSelectorValidatorTest.php new file mode 100644 index 0000000..b6f3192 --- /dev/null +++ b/tests/Broker/Redis/RedisChannelSelectorValidatorTest.php @@ -0,0 +1,57 @@ +assertValid('public.news'); + + $this->expectNotToPerformAssertions(); + } + + public function testRejectsPatternsWhenPatternSubscriptionsAreDisabled(): void + { + $validator = new RedisChannelSelectorValidator(new RedisConfig()); + + $this->expectException(InvalidChannelException::class); + $this->expectExceptionMessage('Redis pattern subscriptions are disabled.'); + + $validator->assertValid('public.*'); + } + + public function testAllowsValidPatternsWhenPatternSubscriptionsAreEnabled(): void + { + $validator = new RedisChannelSelectorValidator( + new RedisConfig(allowPatternSubscriptions: true), + ); + + $validator->assertValid('public.*'); + + $this->expectNotToPerformAssertions(); + } + + public function testRejectsInvalidPatternsWhenPatternSubscriptionsAreEnabled(): void + { + $validator = new RedisChannelSelectorValidator( + new RedisConfig(allowPatternSubscriptions: true), + ); + + $this->expectException(InvalidChannelException::class); + + $validator->assertValid('public.*.'); + } +} diff --git a/tests/Broker/Redis/RedisConfigTest.php b/tests/Broker/Redis/RedisConfigTest.php index a3fe29c..1a4ff80 100644 --- a/tests/Broker/Redis/RedisConfigTest.php +++ b/tests/Broker/Redis/RedisConfigTest.php @@ -6,6 +6,8 @@ use Maniaba\CodeIgniterSse\Broker\Redis\Exception\RedisConfigurationException; use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfig; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfigFactory; +use Maniaba\CodeIgniterSse\Config\Sse; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; @@ -31,6 +33,56 @@ public function testNormalizesEmptyCredentials(): void $this->assertNull($config->username); } + public function testFactoryMapsRedisOptions(): void + { + $config = new Sse(); + $config->channelPrefix = 'tenant:sse:'; + $config->redis = [ + 'scheme' => 'tls', + 'host' => 'redis.internal', + 'port' => 6380, + 'username' => 'app', + 'password' => 'secret', + 'database' => 2, + 'connectTimeout' => 1.5, + 'readTimeout' => 2.5, + 'pollInterval' => 0.5, + 'pingInterval' => 10.0, + 'reconnectAttempts' => 3, + 'reconnectDelayMilliseconds' => 100, + 'deduplicationCapacity' => 64, + 'maxPayloadBytes' => 2048, + 'maxResponseElements' => 32, + 'maxResponseDepth' => 3, + 'allowPatternSubscriptions' => true, + 'clientName' => 'ci-sse', + 'streamContext' => ['ssl' => ['verify_peer' => true]], + ]; + + $redis = (new RedisConfigFactory())->create($config); + + $this->assertSame('tls', $redis->scheme); + $this->assertSame('redis.internal', $redis->host); + $this->assertSame(6380, $redis->port); + $this->assertSame('app', $redis->username); + $this->assertSame('secret', $redis->password); + $this->assertSame(2, $redis->database); + $this->assertSame(1.5, $redis->connectTimeout); + $this->assertSame(2.5, $redis->readTimeout); + $this->assertSame('tenant:sse:', $redis->channelPrefix); + $this->assertSame(0.5, $redis->pollIntervalSeconds); + $this->assertSame(10.0, $redis->subscriberPingIntervalSeconds); + $this->assertSame(3, $redis->maxReconnectAttempts); + $this->assertSame(100, $redis->reconnectDelayMilliseconds); + $this->assertSame(64, $redis->deduplicationCapacity); + $this->assertSame(2048, $redis->maxPayloadBytes); + $this->assertSame(32, $redis->maxResponseElements); + $this->assertSame(3, $redis->maxResponseDepth); + $this->assertTrue($redis->allowPatternSubscriptions); + $this->assertSame('ci-sse', $redis->clientName); + $this->assertSame(['ssl' => ['verify_peer' => true]], $redis->streamContext); + } + #[DataProvider('provideRejectsInvalidConfiguration')] public function testRejectsInvalidConfiguration(callable $factory): void { diff --git a/tests/Config/ServicesTest.php b/tests/Config/ServicesTest.php index b9d5096..72c27b7 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -32,6 +32,8 @@ use Maniaba\CodeIgniterSse\Sse as SseManager; use ReflectionProperty; use Tests\Config\Fixtures\ConfiguredChannelAuthorizer; +use Tests\Support\Adapter\BasicBrokerAdapter; +use Tests\Support\Adapter\BasicSubscriptionEndpoint; /** * @internal @@ -78,10 +80,10 @@ public function testRedisConfigMapsAllConnectionSettings(): void 'maxPayloadBytes' => 2048, 'maxResponseElements' => 128, 'maxResponseDepth' => 4, + 'allowPatternSubscriptions' => true, 'clientName' => 'ci-sse', ]; - $config->channelPrefix = 'test:sse:'; - $config->allowPatternSubscriptions = true; + $config->channelPrefix = 'test:sse:'; $publisher = Services::ssePublisher($config, false); $redis = (new ReflectionProperty($publisher, 'config'))->getValue($publisher); @@ -136,7 +138,7 @@ public function testMercurePublisherUsesTheConfiguredBrokerAdapter(): void $this->assertInstanceOf(MercurePublisher::class, $publisher); } - public function testCustomBrokerCanBeConfiguredWithFactories(): void + public function testCustomBrokerCanBeConfiguredWithAnAdapterClosure(): void { $publisher = new class () implements PublisherInterface { public function publish(string $channel, EventInterface $event): void @@ -154,11 +156,12 @@ public function subscribe( } }; + $adapter = new BasicBrokerAdapter($publisher, $subscriber, new BasicSubscriptionEndpoint()); + $config = new Sse(); $config->broker = 'custom'; $config->brokers['custom'] = [ - 'publisher' => static fn (): PublisherInterface => $publisher, - 'subscriber' => static fn (): SubscriberInterface => $subscriber, + 'adapter' => static fn (): BrokerAdapterInterface => $adapter, ]; $brokers = new BrokerFactory(); @@ -234,22 +237,21 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI $this->assertSame($endpoint, $brokers->subscriptionEndpoint($config)); } - public function testCustomBrokerCanUseSimpleClassNames(): void + public function testCustomBrokerCanUseAdapterClassNames(): void { $config = new Sse(); $config->broker = 'custom-null'; $config->brokers['custom-null'] = [ - 'publisher' => NullBroker::class, - 'subscriber' => NullBroker::class, - 'shared' => true, + 'adapter' => BasicBrokerAdapter::class, + 'shared' => true, ]; $brokers = new BrokerFactory(); - $this->assertInstanceOf(NullBroker::class, $brokers->publisher($config)); + $this->assertInstanceOf(BasicBrokerAdapter::class, $brokers->adapter($config)); $this->assertSame( - $brokers->publisher($config), - $brokers->subscriber($config), + $brokers->adapter($config), + $brokers->adapter($config), ); } @@ -277,12 +279,13 @@ public function subscribe( } }; + $adapter = new BasicBrokerAdapter($publisher, $subscriber, new BasicSubscriptionEndpoint()); + $config = new Sse(); $config->broker = 'custom'; $config->toolbar = ['brokers' => ['custom']]; $config->brokers['custom'] = [ - 'publisher' => static fn (): PublisherInterface => $publisher, - 'subscriber' => static fn (): SubscriberInterface => $subscriber, + 'adapter' => static fn (): BrokerAdapterInterface => $adapter, ]; $traceable = (new BrokerFactory(enableToolbarTracing: true))->publisher($config); diff --git a/tests/Config/SseConfigTest.php b/tests/Config/SseConfigTest.php index 47b2569..863ec91 100644 --- a/tests/Config/SseConfigTest.php +++ b/tests/Config/SseConfigTest.php @@ -63,33 +63,15 @@ static function (Sse $config): void { $config->withCredentials = true; }, ]; + } - yield 'mercure patterns' => [ - static function (Sse $config): void { - $config->broker = 'mercure'; - $config->allowPatternSubscriptions = true; - $config->mercure = [ - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', - ]; - }, - ]; + public function testBrokerSpecificValidationIsLeftToBrokerFactories(): void + { + $config = new Sse(); + $config->broker = 'mercure'; - yield 'missing mercure keys' => [ - static function (Sse $config): void { - $config->broker = 'mercure'; - }, - ]; + $config->validate(); - yield 'private mercure without subscriber authorization' => [ - static function (Sse $config): void { - $config->broker = 'mercure'; - $config->mercure = [ - 'private' => true, - 'authorizeSubscribers' => false, - 'publisherKey' => 'publisher-test-secret', - ]; - }, - ]; + $this->assertSame('mercure', $config->broker); } } diff --git a/tests/Factory/BrokerAdapterResolverTest.php b/tests/Factory/BrokerAdapterResolverTest.php index 7ea0954..c7d7db1 100644 --- a/tests/Factory/BrokerAdapterResolverTest.php +++ b/tests/Factory/BrokerAdapterResolverTest.php @@ -8,8 +8,6 @@ use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; -use Maniaba\CodeIgniterSse\Event\EventFactory; -use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerAdapterResolver; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; use PHPUnit\Framework\TestCase; @@ -208,19 +206,12 @@ public function testRejectsNonArrayBrokerDefinition(): void (new BrokerAdapterResolver())->resolve($config); } - public function testFallsBackToLegacyPublisherSubscriberDefinition(): void + public function testRejectsBrokerDefinitionsWithoutFactoryOrAdapter(): void { - $publisher = (new BasicBrokerAdapter())->publisher(); - $subscriber = (new BasicBrokerAdapter())->subscriber(); - $config = $this->config([ - 'publisher' => static fn () => $publisher, - 'subscriber' => static fn () => $subscriber, - ]); - - $adapter = (new BrokerAdapterResolver(new JsonEventSerializer(), new EventFactory()))->resolve($config); + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must define either "factory" or "adapter"'); - $this->assertInstanceOf(BrokerAdapterInterface::class, $adapter); - $this->assertSame($publisher, $adapter->publisher()); + (new BrokerAdapterResolver())->resolve($this->config([])); } /** diff --git a/tests/Factory/LegacyBrokerAdapterFactoryTest.php b/tests/Factory/LegacyBrokerAdapterFactoryTest.php deleted file mode 100644 index 61bb6a8..0000000 --- a/tests/Factory/LegacyBrokerAdapterFactoryTest.php +++ /dev/null @@ -1,255 +0,0 @@ -create( - $this->config([ - 'publisher' => static fn (): PublisherInterface => $publisher, - 'subscriber' => static fn (): SubscriberInterface => $subscriber, - ]), - $this->context(), - ); - - $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); - $this->assertInstanceOf(LocalBrokerAdapter::class, $adapter); - $this->assertSame($publisher, $adapter->publisher()); - $this->assertSame($subscriber, $adapter->subscriber()); - $this->assertInstanceOf(LocalSseSubscriptionEndpoint::class, $adapter->subscriptionEndpoint()); - } - - public function testCreatesLocalAdapterFromLegacyInvokableDefinitions(): void - { - $publisher = new RecordingPublisher(); - $subscriber = new RecordingSubscriber(); - - $adapter = (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => new class ($publisher) { - public function __construct( - private readonly PublisherInterface $publisher, - ) { - } - - public function __invoke(): PublisherInterface - { - return $this->publisher; - } - }, - 'subscriber' => new class ($subscriber) { - public function __construct( - private readonly SubscriberInterface $subscriber, - ) { - } - - public function __invoke(): SubscriberInterface - { - return $this->subscriber; - } - }, - ]), - $this->context(), - ); - - $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); - $this->assertSame($publisher, $adapter->publisher()); - $this->assertSame($subscriber, $adapter->subscriber()); - } - - public function testLegacySharedDefinitionReusesSingleBrokerObject(): void - { - RecordingBroker::reset(); - - $adapter = (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => RecordingBroker::class, - 'subscriber' => RecordingBroker::class, - 'shared' => true, - ]), - $this->context(), - ); - - $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); - $this->assertSame($adapter->publisher(), $adapter->subscriber()); - $this->assertSame(1, RecordingBroker::$constructed); - } - - public function testLegacyNonSharedDefinitionCreatesPublisherAndSubscriberSeparately(): void - { - RecordingBroker::reset(); - - $adapter = (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => RecordingBroker::class, - 'subscriber' => RecordingBroker::class, - ]), - $this->context(), - ); - - $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); - $this->assertNotSame($adapter->publisher(), $adapter->subscriber()); - $this->assertSame(2, RecordingBroker::$constructed); - } - - public function testCreatesMercureAdapterFromLegacyTransportDefinition(): void - { - $adapter = (new LegacyBrokerAdapterFactory())->create( - $this->mercureConfig([ - 'publisher' => MercurePublisher::class, - 'transport' => 'mercure', - ]), - $this->context(), - ); - - $this->assertInstanceOf(MercureBrokerAdapter::class, $adapter); - $this->assertInstanceOf(MercurePublisher::class, $adapter->publisher()); - } - - public function testCreatesLegacyRedisPublisherAndSubscriber(): void - { - $adapter = (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => RedisPublisher::class, - 'subscriber' => RedisSubscriber::class, - ]), - $this->context(), - ); - - $this->assertInstanceOf(SubscriberAwareBrokerAdapterInterface::class, $adapter); - $this->assertInstanceOf(RedisPublisher::class, $adapter->publisher()); - $this->assertInstanceOf(RedisSubscriber::class, $adapter->subscriber()); - } - - public function testRejectsNonArrayDefinition(): void - { - $config = new Sse(); - $config->broker = 'legacy'; - (new ReflectionProperty($config, 'brokers'))->setValue($config, ['legacy' => 'invalid']); - - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The configured SSE broker definition must be an array.'); - - (new LegacyBrokerAdapterFactory())->create($config, $this->context()); - } - - public function testRejectsInvalidPublisherDefinition(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The configured SSE publisher must implement ' . PublisherInterface::class); - - (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => static fn (): stdClass => new stdClass(), - 'subscriber' => static fn (): SubscriberInterface => new RecordingSubscriber(), - ]), - $this->context(), - ); - } - - public function testRejectsInvalidSubscriberDefinition(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The configured SSE subscriber must implement ' . SubscriberInterface::class); - - (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => static fn (): PublisherInterface => new RecordingPublisher(), - 'subscriber' => static fn (): stdClass => new stdClass(), - ]), - $this->context(), - ); - } - - public function testRejectsMissingBrokerClass(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The configured SSE broker class "MissingLegacyBroker" does not exist.'); - - (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => 'MissingLegacyBroker', - 'subscriber' => static fn (): SubscriberInterface => new RecordingSubscriber(), - ]), - $this->context(), - ); - } - - public function testRejectsMissingSubscriberDefinition(): void - { - $this->expectException(LogicException::class); - $this->expectExceptionMessage('The SSE subscriber broker definition is invalid.'); - - (new LegacyBrokerAdapterFactory())->create( - $this->config([ - 'publisher' => static fn (): PublisherInterface => new RecordingPublisher(), - ]), - $this->context(), - ); - } - - /** - * @param array $definition - */ - private function config(array $definition): Sse - { - $config = new Sse(); - $config->broker = 'legacy'; - $config->brokers['legacy'] = $definition; - - return $config; - } - - /** - * @param array $definition - */ - private function mercureConfig(array $definition): Sse - { - $config = $this->config($definition); - $config->mercure = [ - 'hubUrl' => 'http://mercure/.well-known/mercure', - 'publicHubUrl' => 'https://example.test/.well-known/mercure', - 'publisherKey' => 'publisher-test-secret', - 'subscriberKey' => 'subscriber-test-secret', - ]; - - return $config; - } - - private function context(): BrokerBuildContext - { - return new BrokerBuildContext(new JsonEventSerializer(), new EventFactory()); - } -} diff --git a/tests/HTTP/ChannelRequestParserTest.php b/tests/HTTP/ChannelRequestParserTest.php index 7f6ed31..265c7d0 100644 --- a/tests/HTTP/ChannelRequestParserTest.php +++ b/tests/HTTP/ChannelRequestParserTest.php @@ -4,6 +4,8 @@ namespace Tests\HTTP; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisChannelSelectorValidator; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfig; use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use Maniaba\CodeIgniterSse\Exception\InvalidChannelRequestException; use Maniaba\CodeIgniterSse\HTTP\ChannelRequestParser; @@ -31,10 +33,21 @@ public function testChannelLimitIsEnforced(): void } public function testPatternsAreOptIn(): void + { + $this->expectException(InvalidChannelException::class); + + (new ChannelRequestParser())->parse('public.*'); + } + + public function testRedisValidatorCanAllowPatterns(): void { $this->assertSame( ['public.*'], - (new ChannelRequestParser(20, true))->parse('public.*'), + (new ChannelRequestParser( + validator: new RedisChannelSelectorValidator( + new RedisConfig(allowPatternSubscriptions: true), + ), + ))->parse('public.*'), ); } @@ -42,6 +55,10 @@ public function testPatternValidationMatchesTheRedisSubscriber(): void { $this->expectException(InvalidChannelException::class); - (new ChannelRequestParser(20, true))->parse('public.*.'); + (new ChannelRequestParser( + validator: new RedisChannelSelectorValidator( + new RedisConfig(allowPatternSubscriptions: true), + ), + ))->parse('public.*.'); } } diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index ce388ea..fe6701e 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -8,13 +8,15 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Test\CIUnitTestCase; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\HTTP\LegacySseResponse; -use Maniaba\CodeIgniterSse\HTTP\LocalSseSubscriptionEndpoint; -use Maniaba\CodeIgniterSse\HTTP\MercureSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; +use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; use PHPUnit\Framework\Attributes\DataProvider; use Tests\Support\FixedEventIdGenerator; use Tests\Support\RecordingSubscriber; @@ -86,6 +88,21 @@ public function testLocalEndpointCreatesStreamingResponse(): void $this->assertStringContainsString('"channels":["public.news"]', $output); } + public function testLocalEndpointExposesConfiguredChannelSelectorValidator(): void + { + $validator = new class () implements ChannelSelectorValidatorInterface { + public function assertValid(string $selector): void + { + } + }; + $endpoint = new LocalSseSubscriptionEndpoint( + $this->manager(), + channelSelectorValidator: $validator, + ); + + $this->assertSame($validator, $endpoint->channelSelectorValidator()); + } + public function testMercureEndpointReturnsBootstrapPayloadAndCookie(): void { [$request, $response] = $this->http(); @@ -138,6 +155,13 @@ public function testMercureEndpointDeletesCookieWhenSubscriberAuthorizationIsDis $this->assertSame('', $cookie->getValue()); } + public function testMercureEndpointUsesPlainChannelNameSelectors(): void + { + $endpoint = new MercureSubscriptionEndpoint($this->mercureConfig()); + + $this->assertInstanceOf(ChannelNameValidator::class, $endpoint->channelSelectorValidator()); + } + /** * @return array{RequestInterface, ResponseInterface} */ diff --git a/tests/Integration/MercureIntegrationTest.php b/tests/Integration/MercureIntegrationTest.php index 4275cb9..234430d 100644 --- a/tests/Integration/MercureIntegrationTest.php +++ b/tests/Integration/MercureIntegrationTest.php @@ -5,11 +5,11 @@ namespace Tests\Integration; use CurlMultiHandle; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfigFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercurePublisher; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Event\SseEvent; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; use PHPUnit\Framework\Attributes\Group; use PHPUnit\Framework\TestCase; diff --git a/tests/Stream/SseConnectionManagerTest.php b/tests/Stream/SseConnectionManagerTest.php index 130d1df..83cbe0f 100644 --- a/tests/Stream/SseConnectionManagerTest.php +++ b/tests/Stream/SseConnectionManagerTest.php @@ -11,6 +11,7 @@ use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Event\SseEvent; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; +use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; use PHPUnit\Framework\TestCase; use Tests\Support\FixedEventIdGenerator; use Tests\Support\RecordingSseOutput; @@ -96,7 +97,7 @@ public function isClientConnected(): bool $subscriber, new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator()), - emitConnectedEvent: false, + new SseConnectionOptions(emitConnectedEvent: false), ); $manager->stream($output, ['public.news']); diff --git a/tests/Stream/SseConnectionOptionsTest.php b/tests/Stream/SseConnectionOptionsTest.php new file mode 100644 index 0000000..e0bb26a --- /dev/null +++ b/tests/Stream/SseConnectionOptionsTest.php @@ -0,0 +1,59 @@ +heartbeatInterval = 7; + $config->maxConnectionSeconds = 60; + $config->retryMilliseconds = 1500; + $config->emitConnectedEvent = false; + + $options = SseConnectionOptions::fromConfig($config); + + $this->assertSame(7, $options->heartbeatInterval); + $this->assertSame(60, $options->maximumConnectionSeconds); + $this->assertSame(1500, $options->retryMilliseconds); + $this->assertFalse($options->emitConnectedEvent); + } + + #[DataProvider('provideRejectsInvalidOptions')] + public function testRejectsInvalidOptions(callable $factory): void + { + $this->expectException(InvalidArgumentException::class); + + $factory(); + } + + /** + * @return iterable + */ + public static function provideRejectsInvalidOptions(): iterable + { + yield 'heartbeat' => [ + static fn (): SseConnectionOptions => new SseConnectionOptions(heartbeatInterval: 0), + ]; + + yield 'lifetime' => [ + static fn (): SseConnectionOptions => new SseConnectionOptions(maximumConnectionSeconds: 0), + ]; + + yield 'retry' => [ + static fn (): SseConnectionOptions => new SseConnectionOptions(retryMilliseconds: -1), + ]; + } +} diff --git a/tests/Support/ChannelNameValidatorTest.php b/tests/Support/ChannelNameValidatorTest.php new file mode 100644 index 0000000..57fead7 --- /dev/null +++ b/tests/Support/ChannelNameValidatorTest.php @@ -0,0 +1,29 @@ +assertValid('public.news'); + + $this->expectNotToPerformAssertions(); + } + + public function testRejectsPatternSelectors(): void + { + $this->expectException(InvalidChannelException::class); + + (new ChannelNameValidator())->assertValid('public.*'); + } +} From b2ddce36de511457a382778beb74328d4fa4ba44 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 13:16:27 +0200 Subject: [PATCH 04/12] Refactor test files by moving adapter, factory, and subscriber implementations to a new `_support` directory. --- tests/Broker/LocalBrokerAdapterTest.php | 10 +++++----- tests/Broker/Mercure/MercureBrokerAdapterTest.php | 4 ++-- tests/Broker/Redis/RedisBrokerAdapterTest.php | 10 +++++----- tests/Broker/Redis/RedisHealthCheckerTest.php | 4 ++-- tests/Broker/Redis/RedisPublisherTest.php | 4 ++-- tests/Broker/Redis/RedisSubscriberTest.php | 4 ++-- tests/Config/ServicesTest.php | 6 +++--- tests/Debug/Toolbar/SseEventsCollectorTest.php | 2 +- tests/Debug/Toolbar/TraceablePublisherTest.php | 2 +- tests/Event/SseEventTest.php | 2 +- tests/Factory/BrokerAdapterResolverTest.php | 8 ++++---- tests/HTTP/SseControllerTest.php | 4 ++-- tests/HTTP/SubscriptionEndpointTest.php | 4 ++-- tests/Helpers/SseHelperTest.php | 4 ++-- tests/SseTest.php | 4 ++-- tests/Stream/SseConnectionManagerTest.php | 6 +++--- .../Adapter/BasicBrokerAdapter.php | 6 +++--- .../Adapter/BasicBrokerAdapterFactory.php | 2 +- .../Adapter/BasicSubscriptionEndpoint.php | 2 +- tests/{Support => _support}/Adapter/InvalidAdapter.php | 2 +- .../Adapter/InvalidBrokerAdapterFactory.php | 2 +- tests/{Support => _support}/Adapter/PublisherOnly.php | 2 +- .../{Support => _support}/Adapter/RecordingBroker.php | 2 +- .../Broker/Redis/Fixtures/FakeRedisConnection.php | 2 +- .../Redis/Fixtures/FakeRedisConnectionFactory.php | 2 +- .../Config/Fixtures/ConfiguredChannelAuthorizer.php | 2 +- tests/{Support => _support}/FixedEventIdGenerator.php | 2 +- tests/{Support => _support}/RecordingPublisher.php | 2 +- tests/{Support => _support}/RecordingSseOutput.php | 2 +- tests/{Support => _support}/RecordingSubscriber.php | 2 +- 30 files changed, 55 insertions(+), 55 deletions(-) rename tests/{Support => _support}/Adapter/BasicBrokerAdapter.php (90%) rename tests/{Support => _support}/Adapter/BasicBrokerAdapterFactory.php (95%) rename tests/{Support => _support}/Adapter/BasicSubscriptionEndpoint.php (94%) rename tests/{Support => _support}/Adapter/InvalidAdapter.php (66%) rename tests/{Support => _support}/Adapter/InvalidBrokerAdapterFactory.php (70%) rename tests/{Support => _support}/Adapter/PublisherOnly.php (89%) rename tests/{Support => _support}/Adapter/RecordingBroker.php (96%) rename tests/{ => _support}/Broker/Redis/Fixtures/FakeRedisConnection.php (98%) rename tests/{ => _support}/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php (94%) rename tests/{ => _support}/Config/Fixtures/ConfiguredChannelAuthorizer.php (90%) rename tests/{Support => _support}/FixedEventIdGenerator.php (93%) rename tests/{Support => _support}/RecordingPublisher.php (95%) rename tests/{Support => _support}/RecordingSseOutput.php (97%) rename tests/{Support => _support}/RecordingSubscriber.php (97%) diff --git a/tests/Broker/LocalBrokerAdapterTest.php b/tests/Broker/LocalBrokerAdapterTest.php index 190a2c2..3fcf9fe 100644 --- a/tests/Broker/LocalBrokerAdapterTest.php +++ b/tests/Broker/LocalBrokerAdapterTest.php @@ -18,11 +18,11 @@ use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; use PHPUnit\Framework\TestCase; -use Tests\Support\Adapter\BasicSubscriptionEndpoint; -use Tests\Support\Adapter\PublisherOnly; -use Tests\Support\Adapter\RecordingBroker; -use Tests\Support\RecordingPublisher; -use Tests\Support\RecordingSubscriber; +use Support\Tests\Adapter\BasicSubscriptionEndpoint; +use Support\Tests\Adapter\PublisherOnly; +use Support\Tests\Adapter\RecordingBroker; +use Support\Tests\RecordingPublisher; +use Support\Tests\RecordingSubscriber; /** * @internal diff --git a/tests/Broker/Mercure/MercureBrokerAdapterTest.php b/tests/Broker/Mercure/MercureBrokerAdapterTest.php index e68af1a..f872b48 100644 --- a/tests/Broker/Mercure/MercureBrokerAdapterTest.php +++ b/tests/Broker/Mercure/MercureBrokerAdapterTest.php @@ -16,8 +16,8 @@ use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; use PHPUnit\Framework\TestCase; -use Tests\Support\Adapter\BasicSubscriptionEndpoint; -use Tests\Support\RecordingPublisher; +use Support\Tests\Adapter\BasicSubscriptionEndpoint; +use Support\Tests\RecordingPublisher; /** * @internal diff --git a/tests/Broker/Redis/RedisBrokerAdapterTest.php b/tests/Broker/Redis/RedisBrokerAdapterTest.php index 8804a05..50a5fc8 100644 --- a/tests/Broker/Redis/RedisBrokerAdapterTest.php +++ b/tests/Broker/Redis/RedisBrokerAdapterTest.php @@ -19,11 +19,11 @@ use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; use PHPUnit\Framework\TestCase; -use Tests\Broker\Redis\Fixtures\FakeRedisConnection; -use Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; -use Tests\Support\Adapter\BasicSubscriptionEndpoint; -use Tests\Support\RecordingPublisher; -use Tests\Support\RecordingSubscriber; +use Support\Tests\Adapter\BasicSubscriptionEndpoint; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnection; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; +use Support\Tests\RecordingPublisher; +use Support\Tests\RecordingSubscriber; /** * @internal diff --git a/tests/Broker/Redis/RedisHealthCheckerTest.php b/tests/Broker/Redis/RedisHealthCheckerTest.php index ac5ce36..0213b0d 100644 --- a/tests/Broker/Redis/RedisHealthCheckerTest.php +++ b/tests/Broker/Redis/RedisHealthCheckerTest.php @@ -7,8 +7,8 @@ use Maniaba\CodeIgniterSse\Broker\Redis\Exception\RedisConnectionException; use Maniaba\CodeIgniterSse\Broker\Redis\RedisHealthChecker; use PHPUnit\Framework\TestCase; -use Tests\Broker\Redis\Fixtures\FakeRedisConnection; -use Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnection; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; /** * @internal diff --git a/tests/Broker/Redis/RedisPublisherTest.php b/tests/Broker/Redis/RedisPublisherTest.php index 567e608..df50834 100644 --- a/tests/Broker/Redis/RedisPublisherTest.php +++ b/tests/Broker/Redis/RedisPublisherTest.php @@ -12,8 +12,8 @@ use Maniaba\CodeIgniterSse\Contracts\SerializerInterface; use Maniaba\CodeIgniterSse\Event\SseEvent; use PHPUnit\Framework\TestCase; -use Tests\Broker\Redis\Fixtures\FakeRedisConnection; -use Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnection; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; /** * @internal diff --git a/tests/Broker/Redis/RedisSubscriberTest.php b/tests/Broker/Redis/RedisSubscriberTest.php index c5c707d..74b1a56 100644 --- a/tests/Broker/Redis/RedisSubscriberTest.php +++ b/tests/Broker/Redis/RedisSubscriberTest.php @@ -15,8 +15,8 @@ use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use PHPUnit\Framework\TestCase; use RuntimeException; -use Tests\Broker\Redis\Fixtures\FakeRedisConnection; -use Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnection; +use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnectionFactory; /** * @internal diff --git a/tests/Config/ServicesTest.php b/tests/Config/ServicesTest.php index 72c27b7..225b103 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -31,9 +31,9 @@ use Maniaba\CodeIgniterSse\Factory\BrokerFactory; use Maniaba\CodeIgniterSse\Sse as SseManager; use ReflectionProperty; -use Tests\Config\Fixtures\ConfiguredChannelAuthorizer; -use Tests\Support\Adapter\BasicBrokerAdapter; -use Tests\Support\Adapter\BasicSubscriptionEndpoint; +use Support\Tests\Adapter\BasicBrokerAdapter; +use Support\Tests\Adapter\BasicSubscriptionEndpoint; +use Support\Tests\Config\Fixtures\ConfiguredChannelAuthorizer; /** * @internal diff --git a/tests/Debug/Toolbar/SseEventsCollectorTest.php b/tests/Debug/Toolbar/SseEventsCollectorTest.php index 7bf9db4..1896904 100644 --- a/tests/Debug/Toolbar/SseEventsCollectorTest.php +++ b/tests/Debug/Toolbar/SseEventsCollectorTest.php @@ -9,7 +9,7 @@ use Maniaba\CodeIgniterSse\Debug\Toolbar\TraceablePublisher; use Maniaba\CodeIgniterSse\Event\SseEvent; use PHPUnit\Framework\TestCase; -use Tests\Support\RecordingPublisher; +use Support\Tests\RecordingPublisher; /** * @internal diff --git a/tests/Debug/Toolbar/TraceablePublisherTest.php b/tests/Debug/Toolbar/TraceablePublisherTest.php index 0d9c1a8..f3cb493 100644 --- a/tests/Debug/Toolbar/TraceablePublisherTest.php +++ b/tests/Debug/Toolbar/TraceablePublisherTest.php @@ -11,7 +11,7 @@ use Maniaba\CodeIgniterSse\Event\SseEvent; use PHPUnit\Framework\TestCase; use RuntimeException; -use Tests\Support\RecordingPublisher; +use Support\Tests\RecordingPublisher; /** * @internal diff --git a/tests/Event/SseEventTest.php b/tests/Event/SseEventTest.php index 4f88b31..eada8f1 100644 --- a/tests/Event/SseEventTest.php +++ b/tests/Event/SseEventTest.php @@ -9,7 +9,7 @@ use Maniaba\CodeIgniterSse\Event\SseEvent; use Maniaba\CodeIgniterSse\Exception\InvalidEventException; use PHPUnit\Framework\TestCase; -use Tests\Support\FixedEventIdGenerator; +use Support\Tests\FixedEventIdGenerator; /** * @internal diff --git a/tests/Factory/BrokerAdapterResolverTest.php b/tests/Factory/BrokerAdapterResolverTest.php index c7d7db1..df6ca9a 100644 --- a/tests/Factory/BrokerAdapterResolverTest.php +++ b/tests/Factory/BrokerAdapterResolverTest.php @@ -13,10 +13,10 @@ use PHPUnit\Framework\TestCase; use ReflectionProperty; use stdClass; -use Tests\Support\Adapter\BasicBrokerAdapter; -use Tests\Support\Adapter\BasicBrokerAdapterFactory; -use Tests\Support\Adapter\InvalidAdapter; -use Tests\Support\Adapter\InvalidBrokerAdapterFactory; +use Support\Tests\Adapter\BasicBrokerAdapter; +use Support\Tests\Adapter\BasicBrokerAdapterFactory; +use Support\Tests\Adapter\InvalidAdapter; +use Support\Tests\Adapter\InvalidBrokerAdapterFactory; /** * @internal diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 387b8b4..25a990b 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -16,8 +16,8 @@ use Maniaba\CodeIgniterSse\HTTP\SseResponseFactory; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Psr\Log\LoggerInterface; -use Tests\Support\FixedEventIdGenerator; -use Tests\Support\RecordingSubscriber; +use Support\Tests\FixedEventIdGenerator; +use Support\Tests\RecordingSubscriber; /** * @internal diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index fe6701e..775dd0d 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -18,8 +18,8 @@ use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; use PHPUnit\Framework\Attributes\DataProvider; -use Tests\Support\FixedEventIdGenerator; -use Tests\Support\RecordingSubscriber; +use Support\Tests\FixedEventIdGenerator; +use Support\Tests\RecordingSubscriber; /** * @internal diff --git a/tests/Helpers/SseHelperTest.php b/tests/Helpers/SseHelperTest.php index 0809505..74e8895 100644 --- a/tests/Helpers/SseHelperTest.php +++ b/tests/Helpers/SseHelperTest.php @@ -8,8 +8,8 @@ use CodeIgniter\Test\CIUnitTestCase; use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Sse; -use Tests\Support\FixedEventIdGenerator; -use Tests\Support\RecordingPublisher; +use Support\Tests\FixedEventIdGenerator; +use Support\Tests\RecordingPublisher; /** * @internal diff --git a/tests/SseTest.php b/tests/SseTest.php index 183bea5..3041247 100644 --- a/tests/SseTest.php +++ b/tests/SseTest.php @@ -11,8 +11,8 @@ use Maniaba\CodeIgniterSse\Sse; use Maniaba\CodeIgniterSse\Support\Channel; use PHPUnit\Framework\TestCase; -use Tests\Support\FixedEventIdGenerator; -use Tests\Support\RecordingPublisher; +use Support\Tests\FixedEventIdGenerator; +use Support\Tests\RecordingPublisher; /** * @internal diff --git a/tests/Stream/SseConnectionManagerTest.php b/tests/Stream/SseConnectionManagerTest.php index 83cbe0f..5a412e0 100644 --- a/tests/Stream/SseConnectionManagerTest.php +++ b/tests/Stream/SseConnectionManagerTest.php @@ -13,9 +13,9 @@ use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; use PHPUnit\Framework\TestCase; -use Tests\Support\FixedEventIdGenerator; -use Tests\Support\RecordingSseOutput; -use Tests\Support\RecordingSubscriber; +use Support\Tests\FixedEventIdGenerator; +use Support\Tests\RecordingSseOutput; +use Support\Tests\RecordingSubscriber; /** * @internal diff --git a/tests/Support/Adapter/BasicBrokerAdapter.php b/tests/_support/Adapter/BasicBrokerAdapter.php similarity index 90% rename from tests/Support/Adapter/BasicBrokerAdapter.php rename to tests/_support/Adapter/BasicBrokerAdapter.php index 8224680..386a880 100644 --- a/tests/Support/Adapter/BasicBrokerAdapter.php +++ b/tests/_support/Adapter/BasicBrokerAdapter.php @@ -2,14 +2,14 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberAwareBrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; -use Tests\Support\RecordingPublisher; -use Tests\Support\RecordingSubscriber; +use Support\Tests\RecordingPublisher; +use Support\Tests\RecordingSubscriber; final class BasicBrokerAdapter implements SubscriberAwareBrokerAdapterInterface { diff --git a/tests/Support/Adapter/BasicBrokerAdapterFactory.php b/tests/_support/Adapter/BasicBrokerAdapterFactory.php similarity index 95% rename from tests/Support/Adapter/BasicBrokerAdapterFactory.php rename to tests/_support/Adapter/BasicBrokerAdapterFactory.php index 39b52eb..dddefc4 100644 --- a/tests/Support/Adapter/BasicBrokerAdapterFactory.php +++ b/tests/_support/Adapter/BasicBrokerAdapterFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; diff --git a/tests/Support/Adapter/BasicSubscriptionEndpoint.php b/tests/_support/Adapter/BasicSubscriptionEndpoint.php similarity index 94% rename from tests/Support/Adapter/BasicSubscriptionEndpoint.php rename to tests/_support/Adapter/BasicSubscriptionEndpoint.php index 48efc26..9f3a82c 100644 --- a/tests/Support/Adapter/BasicSubscriptionEndpoint.php +++ b/tests/_support/Adapter/BasicSubscriptionEndpoint.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; diff --git a/tests/Support/Adapter/InvalidAdapter.php b/tests/_support/Adapter/InvalidAdapter.php similarity index 66% rename from tests/Support/Adapter/InvalidAdapter.php rename to tests/_support/Adapter/InvalidAdapter.php index b25f099..de9c823 100644 --- a/tests/Support/Adapter/InvalidAdapter.php +++ b/tests/_support/Adapter/InvalidAdapter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; final class InvalidAdapter { diff --git a/tests/Support/Adapter/InvalidBrokerAdapterFactory.php b/tests/_support/Adapter/InvalidBrokerAdapterFactory.php similarity index 70% rename from tests/Support/Adapter/InvalidBrokerAdapterFactory.php rename to tests/_support/Adapter/InvalidBrokerAdapterFactory.php index abc2aa0..1e4698b 100644 --- a/tests/Support/Adapter/InvalidBrokerAdapterFactory.php +++ b/tests/_support/Adapter/InvalidBrokerAdapterFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; final class InvalidBrokerAdapterFactory { diff --git a/tests/Support/Adapter/PublisherOnly.php b/tests/_support/Adapter/PublisherOnly.php similarity index 89% rename from tests/Support/Adapter/PublisherOnly.php rename to tests/_support/Adapter/PublisherOnly.php index 5b576f0..d3876f9 100644 --- a/tests/Support/Adapter/PublisherOnly.php +++ b/tests/_support/Adapter/PublisherOnly.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; use Maniaba\CodeIgniterSse\Contracts\EventInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; diff --git a/tests/Support/Adapter/RecordingBroker.php b/tests/_support/Adapter/RecordingBroker.php similarity index 96% rename from tests/Support/Adapter/RecordingBroker.php rename to tests/_support/Adapter/RecordingBroker.php index 92c5d28..d24f17b 100644 --- a/tests/Support/Adapter/RecordingBroker.php +++ b/tests/_support/Adapter/RecordingBroker.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support\Adapter; +namespace Support\Tests\Adapter; use Maniaba\CodeIgniterSse\Contracts\BrokerInterface; use Maniaba\CodeIgniterSse\Contracts\EventInterface; diff --git a/tests/Broker/Redis/Fixtures/FakeRedisConnection.php b/tests/_support/Broker/Redis/Fixtures/FakeRedisConnection.php similarity index 98% rename from tests/Broker/Redis/Fixtures/FakeRedisConnection.php rename to tests/_support/Broker/Redis/Fixtures/FakeRedisConnection.php index 961a6de..60e6656 100644 --- a/tests/Broker/Redis/Fixtures/FakeRedisConnection.php +++ b/tests/_support/Broker/Redis/Fixtures/FakeRedisConnection.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Broker\Redis\Fixtures; +namespace Support\Tests\Broker\Redis\Fixtures; use Maniaba\CodeIgniterSse\Broker\Redis\RedisConnectionInterface; use Maniaba\CodeIgniterSse\Broker\Redis\RedisSubscriptionMessage; diff --git a/tests/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php b/tests/_support/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php similarity index 94% rename from tests/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php rename to tests/_support/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php index f07d30c..35880bc 100644 --- a/tests/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php +++ b/tests/_support/Broker/Redis/Fixtures/FakeRedisConnectionFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Broker\Redis\Fixtures; +namespace Support\Tests\Broker\Redis\Fixtures; use LogicException; use Maniaba\CodeIgniterSse\Broker\Redis\RedisConnectionFactoryInterface; diff --git a/tests/Config/Fixtures/ConfiguredChannelAuthorizer.php b/tests/_support/Config/Fixtures/ConfiguredChannelAuthorizer.php similarity index 90% rename from tests/Config/Fixtures/ConfiguredChannelAuthorizer.php rename to tests/_support/Config/Fixtures/ConfiguredChannelAuthorizer.php index 0182345..dcc1e5d 100644 --- a/tests/Config/Fixtures/ConfiguredChannelAuthorizer.php +++ b/tests/_support/Config/Fixtures/ConfiguredChannelAuthorizer.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Config\Fixtures; +namespace Support\Tests\Config\Fixtures; use Maniaba\CodeIgniterSse\Contracts\ChannelAuthorizerInterface; diff --git a/tests/Support/FixedEventIdGenerator.php b/tests/_support/FixedEventIdGenerator.php similarity index 93% rename from tests/Support/FixedEventIdGenerator.php rename to tests/_support/FixedEventIdGenerator.php index 824e0a0..5bf40c8 100644 --- a/tests/Support/FixedEventIdGenerator.php +++ b/tests/_support/FixedEventIdGenerator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support; +namespace Support\Tests; use Maniaba\CodeIgniterSse\Contracts\EventIdGeneratorInterface; diff --git a/tests/Support/RecordingPublisher.php b/tests/_support/RecordingPublisher.php similarity index 95% rename from tests/Support/RecordingPublisher.php rename to tests/_support/RecordingPublisher.php index f910fe0..70cae9e 100644 --- a/tests/Support/RecordingPublisher.php +++ b/tests/_support/RecordingPublisher.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support; +namespace Support\Tests; use Maniaba\CodeIgniterSse\Contracts\EventInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; diff --git a/tests/Support/RecordingSseOutput.php b/tests/_support/RecordingSseOutput.php similarity index 97% rename from tests/Support/RecordingSseOutput.php rename to tests/_support/RecordingSseOutput.php index a4d0235..358cb51 100644 --- a/tests/Support/RecordingSseOutput.php +++ b/tests/_support/RecordingSseOutput.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support; +namespace Support\Tests; use Maniaba\CodeIgniterSse\Contracts\SseOutputInterface; diff --git a/tests/Support/RecordingSubscriber.php b/tests/_support/RecordingSubscriber.php similarity index 97% rename from tests/Support/RecordingSubscriber.php rename to tests/_support/RecordingSubscriber.php index 84917fc..7eef8ec 100644 --- a/tests/Support/RecordingSubscriber.php +++ b/tests/_support/RecordingSubscriber.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Tests\Support; +namespace Support\Tests; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; use Maniaba\CodeIgniterSse\Event\BrokerMessage; From ece3f2c6ad312189771bbcd583f4c9c1afb5369d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 13:22:18 +0200 Subject: [PATCH 05/12] Added documentation for custom brokers, including contracts, folder layout, registration, configuration, and implementation details. Updated existing documents to reference custom broker documentation. --- docs/configuration.md | 22 +-- docs/custom-brokers.md | 346 +++++++++++++++++++++++++++++++++++++++ docs/index.md | 3 + docs/module-structure.md | 7 + docs/troubleshooting.md | 25 +++ mkdocs.yml | 1 + 6 files changed, 385 insertions(+), 19 deletions(-) create mode 100644 docs/custom-brokers.md diff --git a/docs/configuration.md b/docs/configuration.md index f7783fa..452122b 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,7 +180,7 @@ therefore does not separate SSE traffic; `channelPrefix` is the isolation boundary. Custom brokers are added by registering a new key in `brokers`. A broker -definition must provide either: +definition must provide either `factory` or `adapter`: - `factory`: `BrokerAdapterFactoryInterface`, callable returning one, or class name implementing it; @@ -189,6 +189,8 @@ definition must provide either: The adapter owns publishing and the HTTP subscription endpoint. If the broker can stream through PHP, implement `SubscriberAwareBrokerAdapterInterface` too. +See [Custom brokers](custom-brokers.md) for implementation examples and +troubleshooting. ```php use App\Sse\CustomBrokerAdapterFactory; @@ -223,24 +225,6 @@ final class Sse extends BaseSse } ``` -When a broker needs application services or constructor arguments, use factory -closures: - -```php -use Maniaba\CodeIgniterSse\Config\Sse; -use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; -use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; - -public array $brokers = [ - 'custom' => [ - 'adapter' => static fn ( - Sse $config, - BrokerBuildContext $context, - ): BrokerAdapterInterface => service('customSseBrokerAdapter'), - ], -]; -``` - ## Mercure Mercure options live in one array, parallel to Redis: diff --git a/docs/custom-brokers.md b/docs/custom-brokers.md new file mode 100644 index 0000000..7aa0ea4 --- /dev/null +++ b/docs/custom-brokers.md @@ -0,0 +1,346 @@ +# Custom brokers + +Custom brokers plug into the package through one boundary: a broker adapter. +Do not configure separate `publisher` and `subscriber` classes in +`Sse::$brokers`; that legacy shape is not supported. + +## Required contracts + +Every custom broker definition must resolve to `BrokerAdapterInterface`. + +| Need | Contract | +|---|---| +| Publish application events and provide a subscribe endpoint | `BrokerAdapterInterface` | +| Build the adapter from configuration/services | `BrokerAdapterFactoryInterface` | +| Publish one event to the transport | `PublisherInterface` | +| Let the package stream through PHP | `SubscriberAwareBrokerAdapterInterface` plus `SubscriberInterface` | +| Return a broker-specific HTTP response | `SubscriptionEndpointInterface` | +| Run checks in `php spark sse:health-check` | `HealthCheckableInterface` | +| Accept non-standard channel selectors | `ChannelSelectorValidatorProviderInterface` | + +The minimal adapter contract is: + +```php +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; +use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; + +final readonly class AcmeBrokerAdapter implements BrokerAdapterInterface +{ + public function __construct( + private PublisherInterface $publisher, + private SubscriptionEndpointInterface $endpoint, + ) { + } + + public function publisher(): PublisherInterface + { + return $this->publisher; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } +} +``` + +## Recommended folder layout + +Keep every broker-specific class in its own application folder: + +```text +app/ +└── Sse/ + └── Broker/ + └── Acme/ + ├── AcmeBrokerAdapter.php + ├── AcmeBrokerAdapterFactory.php + ├── AcmeConfig.php + ├── AcmeConfigFactory.php + ├── AcmePublisher.php + ├── AcmeSubscriptionEndpoint.php + └── AcmeSubscriber.php +``` + +`AcmeSubscriber` is needed only when the transport should be streamed by PHP. +Hub-style transports, where the browser connects directly to an external +service, usually need only a publisher and a subscription endpoint. + +## Register the broker + +Register the broker under a key in `app/Config/Sse.php`: + +```php +use App\Sse\Broker\Acme\AcmeBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Config\Sse as BaseSse; + +final class Sse extends BaseSse +{ + public string $broker = 'acme'; + + public array $brokers = [ + 'acme' => [ + 'factory' => AcmeBrokerAdapterFactory::class, + ], + ]; +} +``` + +If the application still needs the built-in brokers, keep their definitions in +the same array or merge them before `Sse::validate()` runs. The package default +definitions are shown in [Configuration](configuration.md#broker). + +If the factory needs application services or constructor arguments, use a +callable factory provider: + +```php +use App\Sse\Broker\Acme\AcmeBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; + +public array $brokers = [ + 'acme' => [ + 'factory' => static fn (): BrokerAdapterFactoryInterface => new AcmeBrokerAdapterFactory( + service('acmeSseClient'), + env('sse.acme.endpoint'), + ), + ], +]; +``` + +The callable receives no arguments. The returned factory receives `Sse` and +`BrokerBuildContext` when the broker is built. + +## Broker-specific configuration + +Keep custom transport options out of the core package config fields. A common +shape is to store broker-specific options beside the broker definition: + +```php +public array $brokers = [ + 'acme' => [ + 'factory' => AcmeBrokerAdapterFactory::class, + 'options' => [ + 'endpoint' => 'https://broker.example.com/sse', + 'token' => null, + ], + ], +]; +``` + +The package resolver ignores unknown keys such as `options`; the custom +factory may read them from `$config->brokers[$config->broker]`. + +For non-trivial options, mirror the built-in Redis and Mercure adapters: put a +small config object and config factory in the broker folder. + +```php +use Maniaba\CodeIgniterSse\Broker\Config\AbstractBrokerConfigFactory; +use Maniaba\CodeIgniterSse\Config\Sse; + +final class AcmeConfigFactory extends AbstractBrokerConfigFactory +{ + public function create(Sse $config): AcmeConfig + { + $definition = $config->brokers[$config->broker] ?? []; + $options = self::arrayOption($definition['options'] ?? null); + + return new AcmeConfig( + endpoint: (string) ($options['endpoint'] ?? ''), + token: self::nullableString($options['token'] ?? null), + ); + } +} +``` + +## Implement the factory + +`BrokerAdapterFactoryInterface` is the normal entry point for a custom broker: + +```php +use App\Sse\Broker\Acme\AcmeBrokerAdapter; +use App\Sse\Broker\Acme\AcmePublisher; +use App\Sse\Broker\Acme\AcmeSubscriptionEndpoint; +use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; + +final readonly class AcmeBrokerAdapterFactory implements BrokerAdapterFactoryInterface +{ + public function __construct( + private AcmeClient $client, + private string $publicEndpoint, + ) { + } + + public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface + { + return new AcmeBrokerAdapter( + new AcmePublisher($this->client, $context->serializer), + new AcmeSubscriptionEndpoint($this->publicEndpoint), + ); + } +} +``` + +`BrokerBuildContext` provides the package serializer and event factory. Use the +serializer when the external transport should receive the standard package +event envelope. + +## Implement publishing + +```php +use Maniaba\CodeIgniterSse\Contracts\EventInterface; +use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; +use Maniaba\CodeIgniterSse\Contracts\SerializerInterface; + +final readonly class AcmePublisher implements PublisherInterface +{ + public function __construct( + private AcmeClient $client, + private SerializerInterface $serializer, + ) { + } + + public function publish(string $channel, EventInterface $event): void + { + $this->client->publish( + $channel, + $this->serializer->serialize($channel, $event), + ); + } +} +``` + +The package validates channels before application code calls +`sse()->publish(...)`, but a custom publisher is still a transport boundary. +Validate or constrain anything that becomes a remote topic, URL, header, or +query parameter. + +## Implement the subscription endpoint + +For Hub-style brokers, return bootstrap data that the browser client can use +to connect to the external service: + +```php +use CodeIgniter\HTTP\RequestInterface; +use CodeIgniter\HTTP\ResponseInterface; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; +use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; + +final readonly class AcmeSubscriptionEndpoint implements + SubscriptionEndpointInterface, + ChannelSelectorValidatorProviderInterface +{ + public function __construct(private string $publicEndpoint) + { + } + + public function channelSelectorValidator(): ChannelSelectorValidatorInterface + { + return new ChannelNameValidator(); + } + + public function respond( + RequestInterface $request, + ResponseInterface $response, + array $channels, + ): ResponseInterface { + return $response + ->setStatusCode(200) + ->setJSON([ + 'transport' => 'acme', + 'endpoint' => $this->publicEndpoint, + 'channels' => $channels, + ]) + ->setHeader('Cache-Control', 'private, no-store') + ->setHeader('X-Content-Type-Options', 'nosniff'); + } +} +``` + +The package authorizes channels before `respond()` is called. The endpoint +receives only approved channel selectors. + +## PHP-stream brokers + +If the broker should keep the browser connected to a PHP SSE response, the +adapter must also implement `SubscriberAwareBrokerAdapterInterface`. The +factory can reuse the built-in local endpoint: + +```php +use Maniaba\CodeIgniterSse\Broker\LocalBrokerAdapter; +use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; +use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; +use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; +use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; + +final readonly class AcmeStreamBrokerAdapterFactory implements BrokerAdapterFactoryInterface +{ + public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface + { + $publisher = new AcmePublisher(service('acmeSseClient'), $context->serializer); + $subscriber = new AcmeSubscriber(service('acmeSseClient'), $context->serializer); + $manager = new SseConnectionManager( + $subscriber, + $context->serializer, + $context->events, + SseConnectionOptions::fromConfig($config), + ); + + return new LocalBrokerAdapter( + $publisher, + $subscriber, + new LocalSseSubscriptionEndpoint($manager, $config->requireAcceptHeader), + ); + } +} +``` + +The subscriber must call `$onMessage` with `BrokerMessage` instances and must +regularly return control to `$onIdle` or `shouldStop` checks so disconnects, +heartbeats, and maximum lifetime can work. + +## Custom channel selectors + +By default the request parser accepts exact package channel names such as +`public.news` or `users.42`. If a broker supports selectors such as patterns, +the endpoint should implement `ChannelSelectorValidatorProviderInterface` and +return a validator that knows that broker's syntax. + +Throw `InvalidChannelException` from the validator when the selector is not +allowed. Keep syntax validation in the broker folder; the core parser should +not know Redis, Mercure, or custom broker rules. + +## Health checks + +If the adapter implements `HealthCheckableInterface`, `php spark +sse:health-check` will render its result. Without that interface the command +prints a skipped result for the broker. + +Use health checks for external dependencies: credentials, sockets, HTTP Hub +availability, TLS configuration, or required PHP extensions. + +## When a custom broker does not work + +Most failures map directly to a missing or wrong contract: + +| Error or symptom | Fix | +|---|---| +| `must define either "factory" or "adapter"` | Add `factory` or `adapter` to `Sse::$brokers[$broker]`. | +| `adapter factory "..." does not exist` | Check namespace, Composer autoload, and class name. Run `composer dump-autoload`. | +| `factory must implement BrokerAdapterFactoryInterface` | Implement `create(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface`. | +| `adapter must implement BrokerAdapterInterface` | Implement `publisher()` and `subscriptionEndpoint()`. | +| `does not provide a PHP subscriber` | Use a custom `SubscriptionEndpointInterface`, or implement `SubscriberAwareBrokerAdapterInterface` and `SubscriberInterface`. | +| Endpoint returns `400 invalid_channels` | The endpoint uses the default exact channel validator. Add `ChannelSelectorValidatorProviderInterface` if the broker supports custom selector syntax. | +| `sse:health-check` says skipped | Implement `HealthCheckableInterface` on the adapter if the broker should be checkable. | + +Do not work around these errors by bypassing `SseController` or accepting +unvalidated channel strings. The adapter/factory contracts are the extension +point. diff --git a/docs/index.md b/docs/index.md index ea0f632..0aeb5c0 100644 --- a/docs/index.md +++ b/docs/index.md @@ -16,6 +16,7 @@ Application publisher ──┤ ├─ EventS - versioned JSON event envelopes with stable event IDs; - Redis Pub/Sub over an internal RESP2 stream client; - Mercure publishing, topic JWT authorization, and direct Hub streaming; +- custom broker adapters through stable package contracts; - logical channel validation, limits, and server-side authorization; - heartbeats, disconnect detection, and maximum connection lifetime; - an SSE response adapter for current CodeIgniter applications; @@ -64,6 +65,8 @@ live.connect(); Private channels are denied until the application supplies an authorizer. Start with the [Quick start](quick-start.md), then configure [channels and authorization](channels-and-authorization.md). +If Redis or Mercure is not the right transport, see +[Custom brokers](custom-brokers.md). ## Delivery model diff --git a/docs/module-structure.md b/docs/module-structure.md index 7b00617..981299a 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -33,6 +33,9 @@ For typed integrations, depend on: - `PublisherInterface` - `SubscriberInterface` +- `BrokerAdapterInterface` +- `BrokerAdapterFactoryInterface` +- `SubscriptionEndpointInterface` - `ChannelAuthorizerInterface` - `UserResolverInterface` - `EventInterface` @@ -51,6 +54,10 @@ because browsers subscribe directly to the Hub. `InMemoryBroker` is for tests and one-process examples. `NullBroker` is useful when applications want the API enabled without delivering live events. +Custom broker implementations should live in their own folder and enter the +package through `BrokerAdapterInterface` or `BrokerAdapterFactoryInterface`. +See [Custom brokers](custom-brokers.md). + ## HTTP layer `HTTP\SseController` parses the channel request, resolves the current user, diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b1119df..5e2e9eb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -83,6 +83,31 @@ Then verify: The package does not use PhpRedis, so installing that extension does not fix a TCP, TLS, ACL, or application configuration problem. +## Custom broker is not loaded + +The broker entry in `Sse::$brokers` must resolve to `BrokerAdapterInterface`. +Use either: + +- `factory`: a `BrokerAdapterFactoryInterface` instance, class name, or + callable returning one; +- `adapter`: a `BrokerAdapterInterface` instance, class name, or callable + returning one. + +If the error says the factory or adapter class does not exist, verify the +namespace and Composer autoload, then run: + +```bash +composer dump-autoload +``` + +If the error says the configured broker does not provide a PHP subscriber, +either implement `SubscriberAwareBrokerAdapterInterface` plus +`SubscriberInterface`, or return a custom `SubscriptionEndpointInterface` that +does not need the PHP stream manager. + +See [Custom brokers](custom-brokers.md) for the exact interfaces and minimal +implementation. + ## Mercure publish fails The Debug Toolbar and thrown `MercurePublishException` include the Hub status. diff --git a/mkdocs.yml b/mkdocs.yml index 86d640e..1977232 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -86,6 +86,7 @@ nav: - Configuration: - Overview: configuration.md - Channels and authorization: channels-and-authorization.md + - Custom brokers: custom-brokers.md - Usage: - Browser client: browser-client.md - Mercure Hub: mercure.md From 6e3d544a914610faed27b422a76595e1ff4b4b18 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 13:28:09 +0200 Subject: [PATCH 06/12] Refactored broker namespaces, moved classes to appropriate folders, and updated references across the codebase. --- docs/configuration.md | 4 ++-- docs/custom-brokers.md | 4 ++-- docs/module-structure.md | 14 +++++++++++--- docs/testing.md | 2 +- .../{Config => }/AbstractBrokerConfigFactory.php | 2 +- src/Broker/{ => InMemory}/InMemoryBroker.php | 2 +- .../InMemoryBrokerAdapterFactory.php | 3 ++- src/Broker/{ => Local}/LocalBrokerAdapter.php | 2 +- .../{ => Local}/LocalBrokerAdapterFactory.php | 2 +- src/Broker/Mercure/MercureBrokerAdapter.php | 2 +- src/Broker/Mercure/MercureConfigFactory.php | 2 +- src/Broker/{ => Null}/NullBroker.php | 2 +- src/Broker/{ => Null}/NullBrokerAdapterFactory.php | 3 ++- src/Broker/Redis/RedisBrokerAdapter.php | 2 +- src/Broker/Redis/RedisConfigFactory.php | 2 +- src/Commands/HealthCheckCommand.php | 2 +- src/Config/Sse.php | 4 ++-- src/Contracts/HealthCheckableInterface.php | 2 +- src/{Broker => Health}/HealthCheckResult.php | 2 +- tests/Broker/HealthCheckResultTest.php | 2 +- tests/Broker/InMemoryBrokerTest.php | 2 +- tests/Broker/LocalBrokerAdapterTest.php | 12 ++++++------ tests/Broker/Mercure/MercureBrokerAdapterTest.php | 2 +- tests/Broker/NullBrokerTest.php | 2 +- tests/Broker/Redis/RedisBrokerAdapterTest.php | 2 +- tests/Config/ServicesTest.php | 4 ++-- 26 files changed, 47 insertions(+), 37 deletions(-) rename src/Broker/{Config => }/AbstractBrokerConfigFactory.php (93%) rename src/Broker/{ => InMemory}/InMemoryBroker.php (97%) rename src/Broker/{ => InMemory}/InMemoryBrokerAdapterFactory.php (82%) rename src/Broker/{ => Local}/LocalBrokerAdapter.php (95%) rename src/Broker/{ => Local}/LocalBrokerAdapterFactory.php (97%) rename src/Broker/{ => Null}/NullBroker.php (95%) rename src/Broker/{ => Null}/NullBrokerAdapterFactory.php (82%) rename src/{Broker => Health}/HealthCheckResult.php (97%) diff --git a/docs/configuration.md b/docs/configuration.md index 452122b..ec2eddb 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -194,9 +194,9 @@ troubleshooting. ```php use App\Sse\CustomBrokerAdapterFactory; -use Maniaba\CodeIgniterSse\Broker\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; -use Maniaba\CodeIgniterSse\Broker\NullBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Null\NullBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; final class Sse extends BaseSse diff --git a/docs/custom-brokers.md b/docs/custom-brokers.md index 7aa0ea4..79ccb19 100644 --- a/docs/custom-brokers.md +++ b/docs/custom-brokers.md @@ -135,7 +135,7 @@ For non-trivial options, mirror the built-in Redis and Mercure adapters: put a small config object and config factory in the broker folder. ```php -use Maniaba\CodeIgniterSse\Broker\Config\AbstractBrokerConfigFactory; +use Maniaba\CodeIgniterSse\Broker\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; final class AcmeConfigFactory extends AbstractBrokerConfigFactory @@ -272,7 +272,7 @@ adapter must also implement `SubscriberAwareBrokerAdapterInterface`. The factory can reuse the built-in local endpoint: ```php -use Maniaba\CodeIgniterSse\Broker\LocalBrokerAdapter; +use Maniaba\CodeIgniterSse\Broker\Local\LocalBrokerAdapter; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; diff --git a/docs/module-structure.md b/docs/module-structure.md index 981299a..525022b 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -7,8 +7,11 @@ and browser behavior separate. src/ ├── Authorization/ ├── Broker/ -│ ├── Config/ +│ ├── AbstractBrokerConfigFactory.php +│ ├── InMemory/ +│ ├── Local/ │ ├── Mercure/ +│ ├── Null/ │ └── Redis/ ├── Commands/ ├── Config/ @@ -16,6 +19,8 @@ src/ ├── Endpoint/ ├── Event/ ├── Exception/ +├── Factory/ +├── Health/ ├── HTTP/ ├── Stream/ └── Support/ @@ -51,8 +56,11 @@ are blocking. Hub configuration and subscription endpoint. Mercure has no PHP subscriber because browsers subscribe directly to the Hub. -`InMemoryBroker` is for tests and one-process examples. `NullBroker` is useful -when applications want the API enabled without delivering live events. +`Broker\InMemory` is for tests and one-process examples. `Broker\Null` is +useful when applications want the API enabled without delivering live events. +`Broker\Local` contains the reusable local adapter used by PHP-stream brokers. +`Broker\AbstractBrokerConfigFactory` is a shared helper for broker-specific +config factories; it is a file rather than a broker folder. Custom broker implementations should live in their own folder and enter the package through `BrokerAdapterInterface` or `BrokerAdapterFactoryInterface`. diff --git a/docs/testing.md b/docs/testing.md index 2cd185b..090ea6d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -55,7 +55,7 @@ Pattern subscription contract, when supported `InMemoryBroker` is useful for one-process unit tests: ```php -use Maniaba\CodeIgniterSse\Broker\InMemoryBroker; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBroker; $broker = new InMemoryBroker(); ``` diff --git a/src/Broker/Config/AbstractBrokerConfigFactory.php b/src/Broker/AbstractBrokerConfigFactory.php similarity index 93% rename from src/Broker/Config/AbstractBrokerConfigFactory.php rename to src/Broker/AbstractBrokerConfigFactory.php index 1aba0a6..8256693 100644 --- a/src/Broker/Config/AbstractBrokerConfigFactory.php +++ b/src/Broker/AbstractBrokerConfigFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker\Config; +namespace Maniaba\CodeIgniterSse\Broker; abstract class AbstractBrokerConfigFactory { diff --git a/src/Broker/InMemoryBroker.php b/src/Broker/InMemory/InMemoryBroker.php similarity index 97% rename from src/Broker/InMemoryBroker.php rename to src/Broker/InMemory/InMemoryBroker.php index fc2df93..1ed5ab1 100644 --- a/src/Broker/InMemoryBroker.php +++ b/src/Broker/InMemory/InMemoryBroker.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Broker\InMemory; use Maniaba\CodeIgniterSse\Contracts\BrokerInterface; use Maniaba\CodeIgniterSse\Contracts\EventInterface; diff --git a/src/Broker/InMemoryBrokerAdapterFactory.php b/src/Broker/InMemory/InMemoryBrokerAdapterFactory.php similarity index 82% rename from src/Broker/InMemoryBrokerAdapterFactory.php rename to src/Broker/InMemory/InMemoryBrokerAdapterFactory.php index 5354a4a..30e88c1 100644 --- a/src/Broker/InMemoryBrokerAdapterFactory.php +++ b/src/Broker/InMemory/InMemoryBrokerAdapterFactory.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Broker\InMemory; +use Maniaba\CodeIgniterSse\Broker\Local\LocalBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; diff --git a/src/Broker/LocalBrokerAdapter.php b/src/Broker/Local/LocalBrokerAdapter.php similarity index 95% rename from src/Broker/LocalBrokerAdapter.php rename to src/Broker/Local/LocalBrokerAdapter.php index 68884f6..2a9ae95 100644 --- a/src/Broker/LocalBrokerAdapter.php +++ b/src/Broker/Local/LocalBrokerAdapter.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Broker\Local; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberAwareBrokerAdapterInterface; diff --git a/src/Broker/LocalBrokerAdapterFactory.php b/src/Broker/Local/LocalBrokerAdapterFactory.php similarity index 97% rename from src/Broker/LocalBrokerAdapterFactory.php rename to src/Broker/Local/LocalBrokerAdapterFactory.php index 9332d78..d1f0151 100644 --- a/src/Broker/LocalBrokerAdapterFactory.php +++ b/src/Broker/Local/LocalBrokerAdapterFactory.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Broker\Local; use LogicException; use Maniaba\CodeIgniterSse\Config\Sse; diff --git a/src/Broker/Mercure/MercureBrokerAdapter.php b/src/Broker/Mercure/MercureBrokerAdapter.php index b37587b..40d36d5 100644 --- a/src/Broker/Mercure/MercureBrokerAdapter.php +++ b/src/Broker/Mercure/MercureBrokerAdapter.php @@ -4,11 +4,11 @@ namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\HealthCheckableInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; final readonly class MercureBrokerAdapter implements BrokerAdapterInterface, HealthCheckableInterface { diff --git a/src/Broker/Mercure/MercureConfigFactory.php b/src/Broker/Mercure/MercureConfigFactory.php index 43b0fc3..2beb7fe 100644 --- a/src/Broker/Mercure/MercureConfigFactory.php +++ b/src/Broker/Mercure/MercureConfigFactory.php @@ -4,7 +4,7 @@ namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use Maniaba\CodeIgniterSse\Broker\Config\AbstractBrokerConfigFactory; +use Maniaba\CodeIgniterSse\Broker\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; final class MercureConfigFactory extends AbstractBrokerConfigFactory diff --git a/src/Broker/NullBroker.php b/src/Broker/Null/NullBroker.php similarity index 95% rename from src/Broker/NullBroker.php rename to src/Broker/Null/NullBroker.php index 5b2b136..de912e4 100644 --- a/src/Broker/NullBroker.php +++ b/src/Broker/Null/NullBroker.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Broker\Null; use Maniaba\CodeIgniterSse\Contracts\BrokerInterface; use Maniaba\CodeIgniterSse\Contracts\EventInterface; diff --git a/src/Broker/NullBrokerAdapterFactory.php b/src/Broker/Null/NullBrokerAdapterFactory.php similarity index 82% rename from src/Broker/NullBrokerAdapterFactory.php rename to src/Broker/Null/NullBrokerAdapterFactory.php index 5d868ea..4844bdc 100644 --- a/src/Broker/NullBrokerAdapterFactory.php +++ b/src/Broker/Null/NullBrokerAdapterFactory.php @@ -2,8 +2,9 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Broker\Null; +use Maniaba\CodeIgniterSse\Broker\Local\LocalBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; diff --git a/src/Broker/Redis/RedisBrokerAdapter.php b/src/Broker/Redis/RedisBrokerAdapter.php index 78f7f05..4a2961c 100644 --- a/src/Broker/Redis/RedisBrokerAdapter.php +++ b/src/Broker/Redis/RedisBrokerAdapter.php @@ -4,12 +4,12 @@ namespace Maniaba\CodeIgniterSse\Broker\Redis; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Contracts\HealthCheckableInterface; use Maniaba\CodeIgniterSse\Contracts\PublisherInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberAwareBrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriberInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; final readonly class RedisBrokerAdapter implements SubscriberAwareBrokerAdapterInterface, HealthCheckableInterface { diff --git a/src/Broker/Redis/RedisConfigFactory.php b/src/Broker/Redis/RedisConfigFactory.php index cc14b6a..9ecfa7e 100644 --- a/src/Broker/Redis/RedisConfigFactory.php +++ b/src/Broker/Redis/RedisConfigFactory.php @@ -4,7 +4,7 @@ namespace Maniaba\CodeIgniterSse\Broker\Redis; -use Maniaba\CodeIgniterSse\Broker\Config\AbstractBrokerConfigFactory; +use Maniaba\CodeIgniterSse\Broker\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; final class RedisConfigFactory extends AbstractBrokerConfigFactory diff --git a/src/Commands/HealthCheckCommand.php b/src/Commands/HealthCheckCommand.php index 8745e09..8c61003 100644 --- a/src/Commands/HealthCheckCommand.php +++ b/src/Commands/HealthCheckCommand.php @@ -6,10 +6,10 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\HealthCheckableInterface; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; final class HealthCheckCommand extends BaseCommand { diff --git a/src/Config/Sse.php b/src/Config/Sse.php index d0a1a8d..de7df4e 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -9,9 +9,9 @@ use LogicException; use Maniaba\CodeIgniterSse\Authorization\NullUserResolver; use Maniaba\CodeIgniterSse\Authorization\PublicChannelAuthorizer; -use Maniaba\CodeIgniterSse\Broker\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; -use Maniaba\CodeIgniterSse\Broker\NullBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Null\NullBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; diff --git a/src/Contracts/HealthCheckableInterface.php b/src/Contracts/HealthCheckableInterface.php index e73e920..9f529b3 100644 --- a/src/Contracts/HealthCheckableInterface.php +++ b/src/Contracts/HealthCheckableInterface.php @@ -4,7 +4,7 @@ namespace Maniaba\CodeIgniterSse\Contracts; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; interface HealthCheckableInterface { diff --git a/src/Broker/HealthCheckResult.php b/src/Health/HealthCheckResult.php similarity index 97% rename from src/Broker/HealthCheckResult.php rename to src/Health/HealthCheckResult.php index 50efc07..2559dea 100644 --- a/src/Broker/HealthCheckResult.php +++ b/src/Health/HealthCheckResult.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Broker; +namespace Maniaba\CodeIgniterSse\Health; use InvalidArgumentException; use Throwable; diff --git a/tests/Broker/HealthCheckResultTest.php b/tests/Broker/HealthCheckResultTest.php index ac4f4ef..9c36e3f 100644 --- a/tests/Broker/HealthCheckResultTest.php +++ b/tests/Broker/HealthCheckResultTest.php @@ -5,7 +5,7 @@ namespace Tests\Broker; use InvalidArgumentException; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; use PHPUnit\Framework\TestCase; use ReflectionClass; use RuntimeException; diff --git a/tests/Broker/InMemoryBrokerTest.php b/tests/Broker/InMemoryBrokerTest.php index 80cf562..9b485c9 100644 --- a/tests/Broker/InMemoryBrokerTest.php +++ b/tests/Broker/InMemoryBrokerTest.php @@ -4,7 +4,7 @@ namespace Tests\Broker; -use Maniaba\CodeIgniterSse\Broker\InMemoryBroker; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBroker; use Maniaba\CodeIgniterSse\Event\SseEvent; use PHPUnit\Framework\TestCase; diff --git a/tests/Broker/LocalBrokerAdapterTest.php b/tests/Broker/LocalBrokerAdapterTest.php index 3fcf9fe..8cff068 100644 --- a/tests/Broker/LocalBrokerAdapterTest.php +++ b/tests/Broker/LocalBrokerAdapterTest.php @@ -5,12 +5,12 @@ namespace Tests\Broker; use LogicException; -use Maniaba\CodeIgniterSse\Broker\InMemoryBroker; -use Maniaba\CodeIgniterSse\Broker\InMemoryBrokerAdapterFactory; -use Maniaba\CodeIgniterSse\Broker\LocalBrokerAdapter; -use Maniaba\CodeIgniterSse\Broker\LocalBrokerAdapterFactory; -use Maniaba\CodeIgniterSse\Broker\NullBroker; -use Maniaba\CodeIgniterSse\Broker\NullBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBroker; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Local\LocalBrokerAdapter; +use Maniaba\CodeIgniterSse\Broker\Local\LocalBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Null\NullBroker; +use Maniaba\CodeIgniterSse\Broker\Null\NullBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\SubscriberAwareBrokerAdapterInterface; use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; diff --git a/tests/Broker/Mercure/MercureBrokerAdapterTest.php b/tests/Broker/Mercure/MercureBrokerAdapterTest.php index f872b48..acf88d9 100644 --- a/tests/Broker/Mercure/MercureBrokerAdapterTest.php +++ b/tests/Broker/Mercure/MercureBrokerAdapterTest.php @@ -4,7 +4,6 @@ namespace Tests\Broker\Mercure; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapter; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfig; @@ -15,6 +14,7 @@ use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; use PHPUnit\Framework\TestCase; use Support\Tests\Adapter\BasicSubscriptionEndpoint; use Support\Tests\RecordingPublisher; diff --git a/tests/Broker/NullBrokerTest.php b/tests/Broker/NullBrokerTest.php index cab424a..9d71d94 100644 --- a/tests/Broker/NullBrokerTest.php +++ b/tests/Broker/NullBrokerTest.php @@ -4,7 +4,7 @@ namespace Tests\Broker; -use Maniaba\CodeIgniterSse\Broker\NullBroker; +use Maniaba\CodeIgniterSse\Broker\Null\NullBroker; use PHPUnit\Framework\TestCase; /** diff --git a/tests/Broker/Redis/RedisBrokerAdapterTest.php b/tests/Broker/Redis/RedisBrokerAdapterTest.php index 50a5fc8..99ce627 100644 --- a/tests/Broker/Redis/RedisBrokerAdapterTest.php +++ b/tests/Broker/Redis/RedisBrokerAdapterTest.php @@ -4,7 +4,6 @@ namespace Tests\Broker\Redis; -use Maniaba\CodeIgniterSse\Broker\HealthCheckResult; use Maniaba\CodeIgniterSse\Broker\Redis\Exception\RedisConnectionException; use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapter; use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; @@ -18,6 +17,7 @@ use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; use PHPUnit\Framework\TestCase; use Support\Tests\Adapter\BasicSubscriptionEndpoint; use Support\Tests\Broker\Redis\Fixtures\FakeRedisConnection; diff --git a/tests/Config/ServicesTest.php b/tests/Config/ServicesTest.php index 225b103..730f266 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -8,9 +8,9 @@ use CodeIgniter\HTTP\RequestInterface; use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Test\CIUnitTestCase; -use Maniaba\CodeIgniterSse\Broker\InMemoryBroker; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBroker; use Maniaba\CodeIgniterSse\Broker\Mercure\MercurePublisher; -use Maniaba\CodeIgniterSse\Broker\NullBroker; +use Maniaba\CodeIgniterSse\Broker\Null\NullBroker; use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfig; use Maniaba\CodeIgniterSse\Broker\Redis\RedisConnectionFactory; use Maniaba\CodeIgniterSse\Broker\Redis\RedisPublisher; From ec3c28f709f0ec0373fe9d0f6a19bc1b3759cdfa Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 13:36:07 +0200 Subject: [PATCH 07/12] Refactored SseEvents collector class to move it from Debug\Toolbar namespace and updated references across the codebase. --- src/Config/Registrar.php | 2 +- .../Toolbar}/SseEvents.php | 3 +- src/HTTP/SseController.php | 55 ++++--------------- tests/Config/ToolbarRegistrarTest.php | 2 +- .../Debug/Toolbar/SseEventsCollectorTest.php | 2 +- tests/HTTP/SseControllerTest.php | 40 +++++++++++--- 6 files changed, 47 insertions(+), 57 deletions(-) rename src/{Collectors => Debug/Toolbar}/SseEvents.php (96%) diff --git a/src/Config/Registrar.php b/src/Config/Registrar.php index ea90837..b95c139 100644 --- a/src/Config/Registrar.php +++ b/src/Config/Registrar.php @@ -4,7 +4,7 @@ namespace Maniaba\CodeIgniterSse\Config; -use Maniaba\CodeIgniterSse\Collectors\SseEvents; +use Maniaba\CodeIgniterSse\Debug\Toolbar\SseEvents; final class Registrar { diff --git a/src/Collectors/SseEvents.php b/src/Debug/Toolbar/SseEvents.php similarity index 96% rename from src/Collectors/SseEvents.php rename to src/Debug/Toolbar/SseEvents.php index cdb6cb7..9b97d45 100644 --- a/src/Collectors/SseEvents.php +++ b/src/Debug/Toolbar/SseEvents.php @@ -2,10 +2,9 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Collectors; +namespace Maniaba\CodeIgniterSse\Debug\Toolbar; use CodeIgniter\Debug\Toolbar\Collectors\BaseCollector; -use Maniaba\CodeIgniterSse\Debug\Toolbar\SseEventHistory; final class SseEvents extends BaseCollector { diff --git a/src/HTTP/SseController.php b/src/HTTP/SseController.php index 05de891..62f6611 100644 --- a/src/HTTP/SseController.php +++ b/src/HTTP/SseController.php @@ -4,39 +4,28 @@ namespace Maniaba\CodeIgniterSse\HTTP; +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Controller; use CodeIgniter\HTTP\ResponseInterface; -use CodeIgniter\RESTful\ResourceController; -use Maniaba\CodeIgniterSse\Config\Services as SseServices; +use LogicException; use Maniaba\CodeIgniterSse\Config\Sse as SseConfig; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; -use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Exception\InvalidChannelException; use Maniaba\CodeIgniterSse\Exception\InvalidChannelRequestException; use Maniaba\CodeIgniterSse\Exception\InvalidOriginException; use Maniaba\CodeIgniterSse\Exception\UnauthorizedChannelException; use Maniaba\CodeIgniterSse\Factory\AuthorizationFactory; -use Maniaba\CodeIgniterSse\Factory\BrokerFactory; -use Maniaba\CodeIgniterSse\Factory\ConnectionManagerFactory; -use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; -final class SseController extends ResourceController +final class SseController extends Controller { - public function __construct( - private readonly ?SseConnectionManager $manager = null, - private readonly ?SseResponseFactory $responseFactory = null, - private readonly ?AuthorizationFactory $authorizations = null, - private readonly ?ConnectionManagerFactory $connectionManagers = null, - private readonly ?SseConfig $config = null, - private readonly ?BrokerFactory $brokers = null, - ) { - } + use ResponseTrait; public function stream(): ResponseInterface { - $config = $this->config ?? SseConfig::discover(); + $config = SseConfig::discover(); $origin = $this->request->getHeaderLine('Origin'); $cors = new CorsPolicy($config->allowedOrigins, $config->withCredentials); @@ -89,7 +78,7 @@ private function authorizeChannels(SseConfig $config, SubscriptionEndpointInterf $validator, ))->parse($this->request->getGet('channels')); - $authorizations = $this->authorizations ?? new AuthorizationFactory(); + $authorizations = new AuthorizationFactory(); $userResolver = $authorizations->userResolver($config); $authorization = $authorizations->channelAuthorization($config); @@ -98,35 +87,13 @@ private function authorizeChannels(SseConfig $config, SubscriptionEndpointInterf private function subscriptionEndpoint(SseConfig $config): SubscriptionEndpointInterface { - if ($this->manager !== null) { - return new LocalSseSubscriptionEndpoint( - $this->manager, - $config->requireAcceptHeader, - $this->responseFactory, - ); - } - - if ($this->connectionManagers !== null) { - return new LocalSseSubscriptionEndpoint( - $this->connectionManagers->create($config), - $config->requireAcceptHeader, - $this->responseFactory, - ); - } - - if ($this->config === null && $this->brokers === null) { - $adapter = service('sseBrokerAdapter'); - - if ($adapter instanceof BrokerAdapterInterface) { - return $adapter->subscriptionEndpoint(); - } - } + $adapter = service('sseBrokerAdapter', $config); - if ($this->brokers !== null) { - return $this->brokers->subscriptionEndpoint($config); + if (! $adapter instanceof BrokerAdapterInterface) { + throw new LogicException('The sseBrokerAdapter service must implement ' . BrokerAdapterInterface::class . '.'); } - return SseServices::sseBrokerAdapter($config, false)->subscriptionEndpoint(); + return $adapter->subscriptionEndpoint(); } private function error(int $status, string $code, string $message): ResponseInterface diff --git a/tests/Config/ToolbarRegistrarTest.php b/tests/Config/ToolbarRegistrarTest.php index c83f8ef..f8cc033 100644 --- a/tests/Config/ToolbarRegistrarTest.php +++ b/tests/Config/ToolbarRegistrarTest.php @@ -6,7 +6,7 @@ use CodeIgniter\Test\CIUnitTestCase; use Config\Toolbar; -use Maniaba\CodeIgniterSse\Collectors\SseEvents; +use Maniaba\CodeIgniterSse\Debug\Toolbar\SseEvents; /** * @internal diff --git a/tests/Debug/Toolbar/SseEventsCollectorTest.php b/tests/Debug/Toolbar/SseEventsCollectorTest.php index 1896904..ae1fd41 100644 --- a/tests/Debug/Toolbar/SseEventsCollectorTest.php +++ b/tests/Debug/Toolbar/SseEventsCollectorTest.php @@ -4,8 +4,8 @@ namespace Tests\Debug\Toolbar; -use Maniaba\CodeIgniterSse\Collectors\SseEvents; use Maniaba\CodeIgniterSse\Debug\Toolbar\SseEventHistory; +use Maniaba\CodeIgniterSse\Debug\Toolbar\SseEvents; use Maniaba\CodeIgniterSse\Debug\Toolbar\TraceablePublisher; use Maniaba\CodeIgniterSse\Event\SseEvent; use PHPUnit\Framework\TestCase; diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 25a990b..7d62491 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -9,13 +9,18 @@ use CodeIgniter\HTTP\ResponseInterface; use CodeIgniter\Superglobals; use CodeIgniter\Test\CIUnitTestCase; +use Config\Services as FrameworkServices; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Config\Sse; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Event\EventFactory; use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\HTTP\SseController; use Maniaba\CodeIgniterSse\HTTP\SseResponseFactory; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Psr\Log\LoggerInterface; +use Support\Tests\Adapter\BasicBrokerAdapter; use Support\Tests\FixedEventIdGenerator; use Support\Tests\RecordingSubscriber; @@ -63,8 +68,12 @@ public function testAuthorizedPublicChannelProducesAStreamingResponse(): void $result = $this->controllerResponse( null, 'text/event-stream', - $manager, - new SseResponseFactory($factoryResponse), + new BasicBrokerAdapter( + endpoint: new LocalSseSubscriptionEndpoint( + $manager, + responseFactory: new SseResponseFactory($factoryResponse), + ), + ), ); ob_start(); @@ -119,7 +128,12 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo $request->removeHeader('Accept'); try { - $controller = new SseController(config: $config); + FrameworkServices::injectMock( + 'sseBrokerAdapter', + new BasicBrokerAdapter(endpoint: new MercureSubscriptionEndpoint($config)), + ); + + $controller = new SseController(); $controller->initController($request, $response, $logger); $result = $controller->stream(); $body = $result->getBody(); @@ -148,14 +162,14 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo $this->assertSame('Lax', $cookie->getSameSite()); } finally { $superglobals->setGetArray($previousGet); + FrameworkServices::resetSingle('sseBrokerAdapter'); } } private function controllerResponse( ?string $origin, ?string $accept, - ?SseConnectionManager $manager = null, - ?SseResponseFactory $responseFactory = null, + ?BrokerAdapterInterface $adapter = null, ): ResponseInterface { $request = single_service('request'); $response = single_service('response'); @@ -176,9 +190,19 @@ private function controllerResponse( $request->setHeader('Accept', $accept); } - $controller = new SseController($manager, $responseFactory); - $controller->initController($request, $response, $logger); + if ($adapter !== null) { + FrameworkServices::injectMock('sseBrokerAdapter', $adapter); + } + + try { + $controller = new SseController(); + $controller->initController($request, $response, $logger); - return $controller->stream(); + return $controller->stream(); + } finally { + if ($adapter !== null) { + FrameworkServices::resetSingle('sseBrokerAdapter'); + } + } } } From 59cc131c2bf92d743010ab44021aa7a557ae5ce7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 13:41:54 +0200 Subject: [PATCH 08/12] Update docs workflow and upgrade documentation for develop channel --- .github/workflows/docs.yml | 53 +++++++++++++++++++++++++++++++++----- docs/upgrade.md | 10 +++++-- 2 files changed, 55 insertions(+), 8 deletions(-) diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index b213ec9..3f27726 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -14,16 +14,16 @@ on: workflow_dispatch: inputs: docs_version: - description: 'Docs version to publish, e.g. 1.1.0' - required: true + description: 'Docs version to publish, e.g. 1.1.0. Leave blank to publish develop.' + required: false type: string source_ref: description: 'Git ref to build from. Defaults to docs_version prefixed with v when available.' required: false type: string update_latest: - description: 'Also move latest alias/default redirect to this version' - required: true + description: 'Also move latest alias/default redirect to this version. Ignored when docs_version is blank.' + required: false default: false type: boolean @@ -65,7 +65,7 @@ jobs: if [[ "$GITHUB_EVENT_NAME" == "push" ]]; then echo "checkout_ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT" echo "docs_version=develop" >> "$GITHUB_OUTPUT" - echo "update_latest=false" >> "$GITHUB_OUTPUT" + echo "update_latest=auto" >> "$GITHUB_OUTPUT" exit 0 fi @@ -91,6 +91,18 @@ jobs: exit 0 fi + if [[ -z "$INPUT_DOCS_VERSION" ]]; then + source_ref="$INPUT_SOURCE_REF" + if [[ -z "$source_ref" ]]; then + source_ref="develop" + fi + + echo "checkout_ref=$source_ref" >> "$GITHUB_OUTPUT" + echo "docs_version=develop" >> "$GITHUB_OUTPUT" + echo "update_latest=auto" >> "$GITHUB_OUTPUT" + exit 0 + fi + docs_version="${INPUT_DOCS_VERSION#v}" validate_docs_version "$docs_version" @@ -141,7 +153,36 @@ jobs: run: | set -euo pipefail - if [[ "$UPDATE_LATEST" == "true" ]]; then + should_update_latest="$UPDATE_LATEST" + + if [[ "$should_update_latest" == "auto" ]]; then + versions_file="$RUNNER_TEMP/docs-versions.json" + + if git show origin/gh-pages:versions.json > "$versions_file" 2>/dev/null \ + && python3 - "$versions_file" <<'PY' + import json + import re + import sys + + with open(sys.argv[1], encoding='utf-8') as handle: + versions = json.load(handle) + + stable_version = re.compile(r'^[0-9]+\.[0-9]+\.[0-9]+$') + + for item in versions: + if stable_version.fullmatch(str(item.get('version', ''))): + sys.exit(0) + + sys.exit(1) + PY + then + should_update_latest=false + else + should_update_latest=true + fi + fi + + if [[ "$should_update_latest" == "true" ]]; then mike deploy --push --update-aliases "$DOCS_VERSION" latest mike set-default --push latest else diff --git a/docs/upgrade.md b/docs/upgrade.md index 55db7ef..53f3504 100644 --- a/docs/upgrade.md +++ b/docs/upgrade.md @@ -40,5 +40,11 @@ and retain a deserializer for previous versions where practical. Documentation is published per package version. Use the version selector in the site header to match the installed Composer package version. -For unreleased work, use the `develop` documentation channel. For installed -releases, use the matching tag version such as `1.0.0`. +Before the first stable release, documentation deploys without a package +version publish the `develop` channel and move the `latest` alias/default +redirect to `develop`. + +After any stable docs version exists, `develop` continues to publish as its own +channel but never moves `latest` again. Tagged stable releases publish their +tag version, such as `1.0.0`, and move `latest` to that release. Prereleases +are published without changing `latest`. From 1278258085936f29b72d01c2830cf9308edababd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 14:20:12 +0200 Subject: [PATCH 09/12] Refactored broker namespaces, moved classes to appropriate folders, and updated references across the codebase. Added tests for Redis subscription handling with reconnect logic. --- src/Broker/AbstractBrokerConfigFactory.php | 36 ---- src/Broker/InMemory/InMemoryBroker.php | 38 ++--- .../Local/LocalBrokerAdapterFactory.php | 1 - .../Mercure/MercureBrokerAdapterFactory.php | 7 +- src/Broker/Mercure/MercureConfigFactory.php | 31 +++- .../Mercure/MercureSubscriptionEndpoint.php | 7 +- src/Broker/Redis/BoundedEventIdSet.php | 14 +- .../Redis/RedisBrokerAdapterFactory.php | 3 +- src/Broker/Redis/RedisConfig.php | 8 +- src/Broker/Redis/RedisConfigFactory.php | 16 +- src/Broker/Redis/RedisHealthChecker.php | 5 - src/Broker/Redis/RedisSubscriber.php | 156 ++++++++++++------ src/Broker/Redis/RedisSubscriptionMessage.php | 5 - src/Endpoint/LocalSseSubscriptionEndpoint.php | 66 +++++++- src/Factory/BrokerAdapterResolver.php | 27 +-- src/Factory/ConnectionManagerFactory.php | 34 ---- src/Factory/MercureSubscriptionFactory.php | 4 +- src/Stream/BrowserEventEncoder.php | 26 +++ src/Stream/SseConnectionManager.php | 18 +- tests/Broker/InMemoryBrokerTest.php | 28 ++++ tests/Broker/Redis/RedisConfigTest.php | 27 +++ tests/Broker/Redis/RedisHealthCheckerTest.php | 2 +- tests/Broker/Redis/RedisSubscriberTest.php | 25 +++ .../Redis/SocketRedisConnectionTest.php | 2 +- tests/Factory/BrokerAdapterResolverTest.php | 49 ++++++ tests/HTTP/SseControllerTest.php | 2 - tests/HTTP/SubscriptionEndpointTest.php | 30 +++- tests/Stream/SseConnectionManagerTest.php | 4 - 28 files changed, 445 insertions(+), 226 deletions(-) delete mode 100644 src/Broker/AbstractBrokerConfigFactory.php delete mode 100644 src/Factory/ConnectionManagerFactory.php create mode 100644 src/Stream/BrowserEventEncoder.php diff --git a/src/Broker/AbstractBrokerConfigFactory.php b/src/Broker/AbstractBrokerConfigFactory.php deleted file mode 100644 index 8256693..0000000 --- a/src/Broker/AbstractBrokerConfigFactory.php +++ /dev/null @@ -1,36 +0,0 @@ - - */ - protected static function stringList(mixed $value): array - { - if (! is_array($value)) { - return []; - } - - return array_values(array_filter( - $value, - is_string(...), - )); - } - - /** - * @return array - */ - protected static function arrayOption(mixed $value): array - { - return is_array($value) ? $value : []; - } -} diff --git a/src/Broker/InMemory/InMemoryBroker.php b/src/Broker/InMemory/InMemoryBroker.php index 1ed5ab1..3fd4d14 100644 --- a/src/Broker/InMemory/InMemoryBroker.php +++ b/src/Broker/InMemory/InMemoryBroker.php @@ -31,51 +31,37 @@ public function subscribe( ?callable $onIdle = null, ): void { $allowed = []; + $cursor = 0; foreach ($channels as $channel) { $name = Channel::from($channel)->value(); $allowed[$name] = true; } - foreach ($this->messages as $message) { + while (true) { if ($shouldStop !== null && $shouldStop()) { return; } - if (! isset($allowed[$message->channel()])) { - continue; - } + while (isset($this->messages[$cursor])) { + $message = $this->messages[$cursor++]; - $onMessage($message); - } + if (! isset($allowed[$message->channel()])) { + continue; + } - if ($shouldStop === null) { - if ($onIdle !== null) { - $onIdle(); + $onMessage($message); } - return; - } - - while (! $shouldStop()) { if ($onIdle !== null) { $onIdle(); } + if ($shouldStop === null) { + return; + } + usleep(250_000); } } - - /** - * @return list - */ - public function messages(): array - { - return $this->messages; - } - - public function clear(): void - { - $this->messages = []; - } } diff --git a/src/Broker/Local/LocalBrokerAdapterFactory.php b/src/Broker/Local/LocalBrokerAdapterFactory.php index d1f0151..09cada4 100644 --- a/src/Broker/Local/LocalBrokerAdapterFactory.php +++ b/src/Broker/Local/LocalBrokerAdapterFactory.php @@ -38,7 +38,6 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI $manager = new SseConnectionManager( $broker, - $context->serializer, $context->events, SseConnectionOptions::fromConfig($config), ); diff --git a/src/Broker/Mercure/MercureBrokerAdapterFactory.php b/src/Broker/Mercure/MercureBrokerAdapterFactory.php index dac012e..f323ec0 100644 --- a/src/Broker/Mercure/MercureBrokerAdapterFactory.php +++ b/src/Broker/Mercure/MercureBrokerAdapterFactory.php @@ -8,6 +8,7 @@ use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Factory\BrokerBuildContext; +use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; final readonly class MercureBrokerAdapterFactory implements BrokerAdapterFactoryInterface { @@ -24,7 +25,11 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI return new MercureBrokerAdapter( $mercure, new MercurePublisher($mercure, $context->serializer), - new MercureSubscriptionEndpoint($config, configs: $configs), + new MercureSubscriptionEndpoint( + $config, + new MercureSubscriptionFactory(mercure: $mercure), + mercure: $mercure, + ), ); } } diff --git a/src/Broker/Mercure/MercureConfigFactory.php b/src/Broker/Mercure/MercureConfigFactory.php index 2beb7fe..e65dab7 100644 --- a/src/Broker/Mercure/MercureConfigFactory.php +++ b/src/Broker/Mercure/MercureConfigFactory.php @@ -4,10 +4,9 @@ namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use Maniaba\CodeIgniterSse\Broker\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; -final class MercureConfigFactory extends AbstractBrokerConfigFactory +final class MercureConfigFactory { public function create(Sse $config): MercureConfig { @@ -43,4 +42,32 @@ public function create(Sse $config): MercureConfig cookieSameSite: (string) ($cookie['sameSite'] ?? 'Lax'), ); } + + private static function nullableString(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @return list + */ + private static function stringList(mixed $value): array + { + if (! is_array($value)) { + return []; + } + + return array_values(array_filter( + $value, + is_string(...), + )); + } + + /** + * @return array + */ + private static function arrayOption(mixed $value): array + { + return is_array($value) ? $value : []; + } } diff --git a/src/Broker/Mercure/MercureSubscriptionEndpoint.php b/src/Broker/Mercure/MercureSubscriptionEndpoint.php index a35cce3..3bc66b3 100644 --- a/src/Broker/Mercure/MercureSubscriptionEndpoint.php +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -19,6 +19,7 @@ public function __construct( private Sse $config, private ?MercureSubscriptionFactory $subscriptions = null, private ?MercureConfigFactory $configs = null, + private ?MercureConfig $mercure = null, ) { } @@ -32,9 +33,9 @@ public function respond( ResponseInterface $response, array $channels, ): ResponseInterface { - $subscription = ($this->subscriptions ?? new MercureSubscriptionFactory()) - ->create($this->config, $channels); - $mercure = ($this->configs ?? new MercureConfigFactory())->create($this->config); + $subscriptions = $this->subscriptions ?? new MercureSubscriptionFactory(mercure: $this->mercure); + $subscription = $subscriptions->create($this->config, $channels); + $mercure = $this->mercure ?? ($this->configs ?? new MercureConfigFactory())->create($this->config); $response = $response ->setStatusCode(200) diff --git a/src/Broker/Redis/BoundedEventIdSet.php b/src/Broker/Redis/BoundedEventIdSet.php index 274e65d..8b15a78 100644 --- a/src/Broker/Redis/BoundedEventIdSet.php +++ b/src/Broker/Redis/BoundedEventIdSet.php @@ -17,7 +17,7 @@ final class BoundedEventIdSet /** * @var array */ - private array $ids = []; + private array $keys = []; /** * @var SplQueue @@ -31,19 +31,19 @@ public function __construct( } /** - * Returns true when the ID was already present. + * Returns true when the key was already present. */ - public function containsOrAdd(string $id): bool + public function containsOrAdd(string $key): bool { - if (isset($this->ids[$id])) { + if (isset($this->keys[$key])) { return true; } - $this->ids[$id] = true; - $this->order->enqueue($id); + $this->keys[$key] = true; + $this->order->enqueue($key); if ($this->order->count() > $this->capacity) { - unset($this->ids[$this->order->dequeue()]); + unset($this->keys[$this->order->dequeue()]); } return false; diff --git a/src/Broker/Redis/RedisBrokerAdapterFactory.php b/src/Broker/Redis/RedisBrokerAdapterFactory.php index 4b2b7b0..ef2046f 100644 --- a/src/Broker/Redis/RedisBrokerAdapterFactory.php +++ b/src/Broker/Redis/RedisBrokerAdapterFactory.php @@ -27,7 +27,6 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI $subscriber = new RedisSubscriber($redis, $context->serializer, $connectionFactory); $manager = new SseConnectionManager( $subscriber, - $context->serializer, $context->events, SseConnectionOptions::fromConfig($config), ); @@ -41,7 +40,7 @@ public function create(Sse $config, BrokerBuildContext $context): BrokerAdapterI $config->requireAcceptHeader, channelSelectorValidator: new RedisChannelSelectorValidator($redis), ), - new RedisHealthChecker(new RedisConnectionFactory($redis)), + new RedisHealthChecker($connectionFactory), ); } } diff --git a/src/Broker/Redis/RedisConfig.php b/src/Broker/Redis/RedisConfig.php index 8ff3de3..134d855 100644 --- a/src/Broker/Redis/RedisConfig.php +++ b/src/Broker/Redis/RedisConfig.php @@ -19,13 +19,13 @@ public function __construct( public int $port = 6379, ?string $password = null, public int $database = 0, - public float $connectTimeout = 2.0, - public float $readTimeout = 2.0, + public float $connectTimeout = 2.5, + public float $readTimeout = 2.5, public string $channelPrefix = 'app:sse:', public float $pollIntervalSeconds = 1.0, public float $subscriberPingIntervalSeconds = 15.0, - public int $maxReconnectAttempts = 3, - public int $reconnectDelayMilliseconds = 100, + public int $maxReconnectAttempts = 2, + public int $reconnectDelayMilliseconds = 250, public int $deduplicationCapacity = 1024, public int $maxPayloadBytes = 1_048_576, public int $maxResponseElements = 1024, diff --git a/src/Broker/Redis/RedisConfigFactory.php b/src/Broker/Redis/RedisConfigFactory.php index 9ecfa7e..420aa52 100644 --- a/src/Broker/Redis/RedisConfigFactory.php +++ b/src/Broker/Redis/RedisConfigFactory.php @@ -4,10 +4,9 @@ namespace Maniaba\CodeIgniterSse\Broker\Redis; -use Maniaba\CodeIgniterSse\Broker\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; -final class RedisConfigFactory extends AbstractBrokerConfigFactory +final class RedisConfigFactory { public function create(Sse $config): RedisConfig { @@ -36,4 +35,17 @@ public function create(Sse $config): RedisConfig clientName: self::nullableString($redis['clientName'] ?? null), ); } + + private static function nullableString(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @return array + */ + private static function arrayOption(mixed $value): array + { + return is_array($value) ? $value : []; + } } diff --git a/src/Broker/Redis/RedisHealthChecker.php b/src/Broker/Redis/RedisHealthChecker.php index 143ecd0..0254940 100644 --- a/src/Broker/Redis/RedisHealthChecker.php +++ b/src/Broker/Redis/RedisHealthChecker.php @@ -34,11 +34,6 @@ public function check(): bool } } - public function isHealthy(): bool - { - return $this->check(); - } - public function lastError(): ?Throwable { return $this->lastError; diff --git a/src/Broker/Redis/RedisSubscriber.php b/src/Broker/Redis/RedisSubscriber.php index b549d48..107526c 100644 --- a/src/Broker/Redis/RedisSubscriber.php +++ b/src/Broker/Redis/RedisSubscriber.php @@ -46,57 +46,24 @@ public function subscribe( } [$redisChannels, $redisPatterns] = $this->prepareSubscriptions($channels); - $seenIds = new BoundedEventIdSet($this->config->deduplicationCapacity); - $connection = null; - $reconnectAttempts = 0; - $this->subscribing = true; + $seenIds = $redisPatterns === [] + ? null + : new BoundedEventIdSet($this->config->deduplicationCapacity); + $reconnectAttempts = 0; + $this->subscribing = true; try { while (! $this->shouldStop($shouldStop)) { try { - $connection = $this->connectionFactory->create(); - $connection->connect(); - $connection->subscribe($redisChannels, $redisPatterns); - $lastRedisActivity = ($this->clock)(); - - while (! $this->shouldStop($shouldStop)) { - $redisMessage = $connection->readMessage($this->config->pollIntervalSeconds); - - if ($redisMessage === null) { - if ($onIdle !== null) { - $onIdle(); - } - - $now = ($this->clock)(); - - if ( - $now - $lastRedisActivity >= $this->config->subscriberPingIntervalSeconds - ) { - if (! $connection->ping()) { - throw new RedisConnectionException( - 'Redis subscription health check failed.', - ); - } - - $lastRedisActivity = $now; - $reconnectAttempts = 0; - } - - continue; - } - - $lastRedisActivity = ($this->clock)(); - $reconnectAttempts = 0; - $message = $this->deserialize($redisMessage); - - if ($message !== null && ! $seenIds->containsOrAdd($message->id())) { - $onMessage($message); - } - - if ($onIdle !== null) { - $onIdle(); - } - } + $this->consumeConnection( + $redisChannels, + $redisPatterns, + $onMessage, + $shouldStop, + $onIdle, + $seenIds, + $reconnectAttempts, + ); } catch (RedisConnectionException $exception) { if ($this->shouldStop($shouldStop)) { return; @@ -113,11 +80,6 @@ public function subscribe( $this->delayReconnect(); } catch (RedisCommandException $exception) { throw new RedisSubscriptionException('Redis rejected the SSE subscription.', 0, $exception); - } finally { - if ($connection !== null) { - $connection->close(); - $connection = null; - } } } } finally { @@ -125,6 +87,96 @@ public function subscribe( } } + /** + * @param list $redisChannels + * @param list $redisPatterns + */ + private function consumeConnection( + array $redisChannels, + array $redisPatterns, + callable $onMessage, + ?callable $shouldStop, + ?callable $onIdle, + ?BoundedEventIdSet $seenIds, + int &$reconnectAttempts, + ): void { + $connection = $this->connectionFactory->create(); + + try { + $connection->connect(); + $connection->subscribe($redisChannels, $redisPatterns); + $lastRedisActivity = ($this->clock)(); + + while (! $this->shouldStop($shouldStop)) { + $redisMessage = $connection->readMessage($this->config->pollIntervalSeconds); + + if ($redisMessage === null) { + $this->handleIdle($connection, $onIdle, $lastRedisActivity, $reconnectAttempts); + + continue; + } + + $lastRedisActivity = ($this->clock)(); + $reconnectAttempts = 0; + + $this->dispatchMessage($redisMessage, $seenIds, $onMessage); + + if ($onIdle !== null) { + $onIdle(); + } + } + } finally { + $connection->close(); + } + } + + private function handleIdle( + RedisConnectionInterface $connection, + ?callable $onIdle, + float &$lastRedisActivity, + int &$reconnectAttempts, + ): void { + if ($onIdle !== null) { + $onIdle(); + } + + $now = ($this->clock)(); + + if ($now - $lastRedisActivity < $this->config->subscriberPingIntervalSeconds) { + return; + } + + if (! $connection->ping()) { + throw new RedisConnectionException('Redis subscription health check failed.'); + } + + $lastRedisActivity = $now; + $reconnectAttempts = 0; + } + + private function dispatchMessage( + RedisSubscriptionMessage $redisMessage, + ?BoundedEventIdSet $seenIds, + callable $onMessage, + ): void { + $message = $this->deserialize($redisMessage); + + if ($message === null || $this->isDuplicate($seenIds, $redisMessage, $message)) { + return; + } + + $onMessage($message); + } + + private function isDuplicate( + ?BoundedEventIdSet $seenIds, + RedisSubscriptionMessage $redisMessage, + BrokerMessage $message, + ): bool { + return $seenIds !== null + && $seenIds->containsOrAdd($redisMessage->channel . "\0" . $message->id()); + } + /** * @param list $channels * diff --git a/src/Broker/Redis/RedisSubscriptionMessage.php b/src/Broker/Redis/RedisSubscriptionMessage.php index ad10d54..beb63a8 100644 --- a/src/Broker/Redis/RedisSubscriptionMessage.php +++ b/src/Broker/Redis/RedisSubscriptionMessage.php @@ -12,9 +12,4 @@ public function __construct( public ?string $pattern = null, ) { } - - public function isPatternMessage(): bool - { - return $this->pattern !== null; - } } diff --git a/src/Endpoint/LocalSseSubscriptionEndpoint.php b/src/Endpoint/LocalSseSubscriptionEndpoint.php index f613133..443d108 100644 --- a/src/Endpoint/LocalSseSubscriptionEndpoint.php +++ b/src/Endpoint/LocalSseSubscriptionEndpoint.php @@ -68,15 +68,73 @@ private function acceptsEventStream(RequestInterface $request): bool return false; } + $quality = 0.0; + $specificity = -1; + foreach (explode(',', $accept) as $mediaRange) { - $mediaType = trim(explode(';', $mediaRange, 2)[0]); + $mediaRange = trim($mediaRange); + + if ($mediaRange === '') { + continue; + } + + [$mediaType, $rangeQuality, $rangeSpecificity] = $this->parseAcceptRange($mediaRange); + + if ( + ($mediaType !== 'text/event-stream' && $mediaType !== 'text/*' && $mediaType !== '*/*') + || $rangeSpecificity < $specificity + ) { + continue; + } - if ($mediaType === 'text/event-stream' || $mediaType === '*/*') { - return true; + if ($rangeSpecificity > $specificity || $rangeQuality > $quality) { + $quality = $rangeQuality; + $specificity = $rangeSpecificity; } } - return false; + return $specificity >= 0 && $quality > 0.0; + } + + /** + * @return array{0: string, 1: float, 2: int} + */ + private function parseAcceptRange(string $mediaRange): array + { + $parts = array_map(trim(...), explode(';', $mediaRange)); + $mediaType = array_shift($parts) ?? ''; + $quality = 1.0; + + foreach ($parts as $parameter) { + if (! str_starts_with($parameter, 'q=')) { + continue; + } + + $quality = $this->parseQuality(substr($parameter, 2)); + + break; + } + + return [$mediaType, $quality, $this->specificity($mediaType)]; + } + + private function parseQuality(string $value): float + { + if (preg_match('/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/D', $value) !== 1) { + return 0.0; + } + + return (float) $value; + } + + private function specificity(string $mediaType): int + { + return match ($mediaType) { + 'text/event-stream' => 2, + 'text/*' => 1, + '*/*' => 0, + default => -1, + }; } private function error( diff --git a/src/Factory/BrokerAdapterResolver.php b/src/Factory/BrokerAdapterResolver.php index 0850e6c..92bb768 100644 --- a/src/Factory/BrokerAdapterResolver.php +++ b/src/Factory/BrokerAdapterResolver.php @@ -4,7 +4,6 @@ namespace Maniaba\CodeIgniterSse\Factory; -use Closure; use LogicException; use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterFactoryInterface; @@ -63,6 +62,12 @@ private function definition(Sse $config): array */ private function make(Sse $config, array $definition): BrokerAdapterInterface { + if (array_key_exists('adapter', $definition) && array_key_exists('factory', $definition)) { + throw new LogicException( + 'The configured SSE broker definition must not define both "factory" and "adapter".', + ); + } + if (array_key_exists('adapter', $definition)) { return $this->makeAdapter($config, $definition['adapter']); } @@ -79,12 +84,8 @@ private function make(Sse $config, array $definition): BrokerAdapterInterface private function makeAdapter(Sse $config, mixed $definition): BrokerAdapterInterface { - if ($definition instanceof Closure) { - $adapter = $definition($config, $this->context()); - - if ($adapter instanceof BrokerAdapterInterface) { - return $adapter; - } + if ($definition instanceof BrokerAdapterInterface) { + return $definition; } if (is_callable($definition) && ! is_string($definition)) { @@ -107,10 +108,6 @@ private function makeAdapter(Sse $config, mixed $definition): BrokerAdapterInter } } - if ($definition instanceof BrokerAdapterInterface) { - return $definition; - } - throw new LogicException( 'The configured SSE broker adapter must implement ' . BrokerAdapterInterface::class . '.', ); @@ -122,14 +119,6 @@ private function makeFactory(mixed $definition): BrokerAdapterFactoryInterface return $definition; } - if ($definition instanceof Closure) { - $factory = $definition(); - - if ($factory instanceof BrokerAdapterFactoryInterface) { - return $factory; - } - } - if (is_callable($definition) && ! is_string($definition)) { $factory = $definition(); diff --git a/src/Factory/ConnectionManagerFactory.php b/src/Factory/ConnectionManagerFactory.php deleted file mode 100644 index fd79f31..0000000 --- a/src/Factory/ConnectionManagerFactory.php +++ /dev/null @@ -1,34 +0,0 @@ -subscriber(), - $serializer, - new EventFactory(), - SseConnectionOptions::fromConfig($config), - ); - } -} diff --git a/src/Factory/MercureSubscriptionFactory.php b/src/Factory/MercureSubscriptionFactory.php index 3a35950..d9c4aad 100644 --- a/src/Factory/MercureSubscriptionFactory.php +++ b/src/Factory/MercureSubscriptionFactory.php @@ -4,6 +4,7 @@ namespace Maniaba\CodeIgniterSse\Factory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfig; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfigFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureJwtFactory; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureSubscription; @@ -15,6 +16,7 @@ public function __construct( private ?MercureConfigFactory $configs = null, private ?MercureJwtFactory $tokens = null, + private ?MercureConfig $mercure = null, ) { } @@ -26,7 +28,7 @@ public function create( array $channels, ?int $issuedAt = null, ): MercureSubscription { - $mercure = ($this->configs ?? new MercureConfigFactory())->create($config); + $mercure = $this->mercure ?? ($this->configs ?? new MercureConfigFactory())->create($config); $topics = (new MercureTopicMapper($mercure->topicPrefix))->mapAll($channels); if (! $mercure->authorizeSubscribers) { diff --git a/src/Stream/BrowserEventEncoder.php b/src/Stream/BrowserEventEncoder.php new file mode 100644 index 0000000..eb6d87b --- /dev/null +++ b/src/Stream/BrowserEventEncoder.php @@ -0,0 +1,26 @@ +encoder; $state->stopWhen(! $output->retry($this->options->retryMilliseconds)); @@ -43,7 +43,7 @@ public function stream(SseOutputInterface $output, array $channels): void ); $state->stopWhen(! $output->event( - $this->serializer->serialize($connected->channel(), $connected->event()), + $this->encoder->encode($connected), $connected->event()->name(), $connected->id(), )); @@ -56,19 +56,13 @@ public function stream(SseOutputInterface $output, array $channels): void try { $this->subscriber->subscribe( channels: $channels, - onMessage: static function (BrokerMessage $message) use ($output, $state): void { + onMessage: static function (BrokerMessage $message) use ($output, $state, $encoder): void { if ($state->isStopped()) { return; } $state->stopWhen(! $output->event( - json_encode( - $message, - JSON_THROW_ON_ERROR - | JSON_UNESCAPED_SLASHES - | JSON_UNESCAPED_UNICODE - | JSON_PRESERVE_ZERO_FRACTION, - ), + $encoder->encode($message), $message->event()->name(), $message->id(), )); @@ -107,7 +101,7 @@ public function stream(SseOutputInterface $output, array $channels): void ); $output->event( - $this->serializer->serialize($error->channel(), $error->event()), + $this->encoder->encode($error), $error->event()->name(), $error->id(), ); diff --git a/tests/Broker/InMemoryBrokerTest.php b/tests/Broker/InMemoryBrokerTest.php index 9b485c9..0a94514 100644 --- a/tests/Broker/InMemoryBrokerTest.php +++ b/tests/Broker/InMemoryBrokerTest.php @@ -50,4 +50,32 @@ static function () use (&$idle): void { $this->assertSame(1, $idle); } + + public function testOpenSubscriptionReceivesMessagesPublishedAfterItStarts(): void + { + $broker = new InMemoryBroker(); + $received = []; + $delivered = false; + $idle = 0; + + $broker->subscribe( + ['public.news'], + static function ($message) use (&$received, &$delivered): void { + $received[] = $message->id(); + $delivered = true; + }, + static function () use (&$delivered): bool { + return $delivered; + }, + static function () use ($broker, &$idle): void { + $idle++; + + if ($idle === 1) { + $broker->publish('public.news', new SseEvent('news.created', [], 'later')); + } + }, + ); + + $this->assertSame(['later'], $received); + } } diff --git a/tests/Broker/Redis/RedisConfigTest.php b/tests/Broker/Redis/RedisConfigTest.php index 1a4ff80..7cdc698 100644 --- a/tests/Broker/Redis/RedisConfigTest.php +++ b/tests/Broker/Redis/RedisConfigTest.php @@ -33,6 +33,33 @@ public function testNormalizesEmptyCredentials(): void $this->assertNull($config->username); } + public function testFactoryDefaultsMatchStandaloneRedisConfigDefaults(): void + { + $standalone = new RedisConfig(); + $factory = (new RedisConfigFactory())->create(new Sse()); + + $this->assertSame($standalone->scheme, $factory->scheme); + $this->assertSame($standalone->host, $factory->host); + $this->assertSame($standalone->port, $factory->port); + $this->assertSame($standalone->username, $factory->username); + $this->assertSame($standalone->password, $factory->password); + $this->assertSame($standalone->database, $factory->database); + $this->assertSame($standalone->connectTimeout, $factory->connectTimeout); + $this->assertSame($standalone->readTimeout, $factory->readTimeout); + $this->assertSame($standalone->channelPrefix, $factory->channelPrefix); + $this->assertSame($standalone->pollIntervalSeconds, $factory->pollIntervalSeconds); + $this->assertSame($standalone->subscriberPingIntervalSeconds, $factory->subscriberPingIntervalSeconds); + $this->assertSame($standalone->maxReconnectAttempts, $factory->maxReconnectAttempts); + $this->assertSame($standalone->reconnectDelayMilliseconds, $factory->reconnectDelayMilliseconds); + $this->assertSame($standalone->deduplicationCapacity, $factory->deduplicationCapacity); + $this->assertSame($standalone->maxPayloadBytes, $factory->maxPayloadBytes); + $this->assertSame($standalone->maxResponseElements, $factory->maxResponseElements); + $this->assertSame($standalone->maxResponseDepth, $factory->maxResponseDepth); + $this->assertSame($standalone->allowPatternSubscriptions, $factory->allowPatternSubscriptions); + $this->assertSame($standalone->clientName, $factory->clientName); + $this->assertSame($standalone->streamContext, $factory->streamContext); + } + public function testFactoryMapsRedisOptions(): void { $config = new Sse(); diff --git a/tests/Broker/Redis/RedisHealthCheckerTest.php b/tests/Broker/Redis/RedisHealthCheckerTest.php index 0213b0d..59c9b18 100644 --- a/tests/Broker/Redis/RedisHealthCheckerTest.php +++ b/tests/Broker/Redis/RedisHealthCheckerTest.php @@ -31,7 +31,7 @@ public function testReportsConnectionFailureAsUnhealthy(): void $connection->connectFailure = new RedisConnectionException('offline'); $checker = new RedisHealthChecker(new FakeRedisConnectionFactory([$connection])); - $this->assertFalse($checker->isHealthy()); + $this->assertFalse($checker->check()); $this->assertSame($connection->connectFailure, $checker->lastError()); $this->assertSame(1, $connection->closeCalls); } diff --git a/tests/Broker/Redis/RedisSubscriberTest.php b/tests/Broker/Redis/RedisSubscriberTest.php index 74b1a56..4483a0f 100644 --- a/tests/Broker/Redis/RedisSubscriberTest.php +++ b/tests/Broker/Redis/RedisSubscriberTest.php @@ -117,6 +117,31 @@ static function ($message) use (&$received): void { $this->assertSame(['same-id'], $received); } + public function testDoesNotDeduplicateTheSameEventIdAcrossDifferentChannels(): void + { + $connection = new FakeRedisConnection(); + $connection->messages = [ + new RedisSubscriptionMessage('app:sse:public.one', $this->payload('public.one', 'same-id')), + new RedisSubscriptionMessage('app:sse:public.two', $this->payload('public.two', 'same-id')), + ]; + $subscriber = new RedisSubscriber( + new RedisConfig(allowPatternSubscriptions: true, reconnectDelayMilliseconds: 0), + $this->serializer, + new FakeRedisConnectionFactory([$connection]), + ); + $received = []; + + $subscriber->subscribe( + ['public.*'], + static function ($message) use (&$received): void { + $received[] = $message->channel(); + }, + static fn (): bool => $connection->readCalls >= 2, + ); + + $this->assertSame(['public.one', 'public.two'], $received); + } + public function testReconnectsWithASeparateConnection(): void { $first = new FakeRedisConnection(); diff --git a/tests/Broker/Redis/SocketRedisConnectionTest.php b/tests/Broker/Redis/SocketRedisConnectionTest.php index e84601f..1c1f156 100644 --- a/tests/Broker/Redis/SocketRedisConnectionTest.php +++ b/tests/Broker/Redis/SocketRedisConnectionTest.php @@ -124,7 +124,7 @@ public function testReadsRegularAndPatternMessagesAndBuffersMessageInterleavedWi $this->assertNotNull($pattern); $this->assertSame('app:sse:news.eu', $pattern->channel); $this->assertSame('app:sse:news.*', $pattern->pattern); - $this->assertTrue($pattern->isPatternMessage()); + $this->assertNotNull($pattern->pattern); $this->assertSame( self::command('PING') diff --git a/tests/Factory/BrokerAdapterResolverTest.php b/tests/Factory/BrokerAdapterResolverTest.php index df6ca9a..f6afce1 100644 --- a/tests/Factory/BrokerAdapterResolverTest.php +++ b/tests/Factory/BrokerAdapterResolverTest.php @@ -99,6 +99,25 @@ public function testRejectsAdapterClosureThatReturnsInvalidValue(): void ])); } + public function testRejectsInvalidAdapterClosureAfterOneInvocation(): void + { + $calls = 0; + + try { + (new BrokerAdapterResolver())->resolve($this->config([ + 'adapter' => static function () use (&$calls): stdClass { + $calls++; + + return new stdClass(); + }, + ])); + + $this->fail('Invalid adapter closures must be rejected.'); + } catch (LogicException) { + $this->assertSame(1, $calls); + } + } + public function testResolvesConfiguredFactoryInstance(): void { $adapter = new BasicBrokerAdapter(); @@ -170,6 +189,25 @@ public function testRejectsFactoryCallableThatReturnsInvalidValue(): void ])); } + public function testRejectsInvalidFactoryClosureAfterOneInvocation(): void + { + $calls = 0; + + try { + (new BrokerAdapterResolver())->resolve($this->config([ + 'factory' => static function () use (&$calls): stdClass { + $calls++; + + return new stdClass(); + }, + ])); + + $this->fail('Invalid factory closures must be rejected.'); + } catch (LogicException) { + $this->assertSame(1, $calls); + } + } + public function testSharedDefinitionsReuseTheResolvedAdapter(): void { $config = $this->config([ @@ -214,6 +252,17 @@ public function testRejectsBrokerDefinitionsWithoutFactoryOrAdapter(): void (new BrokerAdapterResolver())->resolve($this->config([])); } + public function testRejectsBrokerDefinitionsWithFactoryAndAdapter(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must not define both "factory" and "adapter"'); + + (new BrokerAdapterResolver())->resolve($this->config([ + 'factory' => BasicBrokerAdapterFactory::class, + 'adapter' => new BasicBrokerAdapter(), + ])); + } + /** * @param array $definition */ diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 7d62491..4370fa4 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -15,7 +15,6 @@ use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Event\EventFactory; -use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\HTTP\SseController; use Maniaba\CodeIgniterSse\HTTP\SseResponseFactory; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; @@ -53,7 +52,6 @@ public function testAuthorizedPublicChannelProducesAStreamingResponse(): void { $manager = new SseConnectionManager( new RecordingSubscriber(), - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator('connected-id')), ); $factoryResponse = single_service('response'); diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index 775dd0d..101b9f2 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -13,7 +13,6 @@ use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; use Maniaba\CodeIgniterSse\Endpoint\LocalSseSubscriptionEndpoint; use Maniaba\CodeIgniterSse\Event\EventFactory; -use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\HTTP\LegacySseResponse; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; @@ -61,11 +60,39 @@ public static function provideLocalEndpointAcceptsEventStreamCompatibleRequests( yield 'event stream with parameters' => ['text/event-stream; charset=utf-8', true]; + yield 'text wildcard' => ['application/json, text/*;q=0.5', true]; + yield 'wildcard' => ['application/json, */*', true]; yield 'accept header disabled' => [null, false]; } + #[DataProvider('provideLocalEndpointRejectsUnacceptableEventStreamRequests')] + public function testLocalEndpointRejectsUnacceptableEventStreamRequests(string $accept): void + { + [$request, $response] = $this->http($accept); + $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); + + $result = $endpoint->preflight($request, $response); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame(406, $result->getStatusCode()); + } + + /** + * @return iterable + */ + public static function provideLocalEndpointRejectsUnacceptableEventStreamRequests(): iterable + { + yield 'event stream q zero' => ['text/event-stream;q=0']; + + yield 'wildcard q zero' => ['*/*;q=0']; + + yield 'specific q zero wins over wildcard' => ['text/event-stream;q=0, */*;q=1']; + + yield 'text wildcard q zero wins over wildcard' => ['text/*;q=0, */*;q=1']; + } + public function testLocalEndpointCreatesStreamingResponse(): void { [$request, $response] = $this->http('text/event-stream'); @@ -186,7 +213,6 @@ private function manager(): SseConnectionManager { return new SseConnectionManager( new RecordingSubscriber(), - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator('connected-id')), ); } diff --git a/tests/Stream/SseConnectionManagerTest.php b/tests/Stream/SseConnectionManagerTest.php index 5a412e0..7af765c 100644 --- a/tests/Stream/SseConnectionManagerTest.php +++ b/tests/Stream/SseConnectionManagerTest.php @@ -8,7 +8,6 @@ use Maniaba\CodeIgniterSse\Contracts\SseOutputInterface; use Maniaba\CodeIgniterSse\Event\BrokerMessage; use Maniaba\CodeIgniterSse\Event\EventFactory; -use Maniaba\CodeIgniterSse\Event\JsonEventSerializer; use Maniaba\CodeIgniterSse\Event\SseEvent; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Maniaba\CodeIgniterSse\Stream\SseConnectionOptions; @@ -37,7 +36,6 @@ public function testStreamsConnectedAndBrokerEventsWithIds(): void $output = new RecordingSseOutput(); $manager = new SseConnectionManager( $subscriber, - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator('connected-event')), ); @@ -95,7 +93,6 @@ public function isClientConnected(): bool }; $manager = new SseConnectionManager( $subscriber, - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator()), new SseConnectionOptions(emitConnectedEvent: false), ); @@ -112,7 +109,6 @@ public function testFailedRetryWriteDoesNotStartTheSubscriber(): void $output->connected = false; $manager = new SseConnectionManager( $subscriber, - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator()), ); From ae62a80e1f367fb12ab15d5410c1c4296341abae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 14:24:03 +0200 Subject: [PATCH 10/12] Revised configuration constraints for custom brokers and updated documentation accordingly. --- docs/channels-and-authorization.md | 6 +++--- docs/configuration.md | 4 ++-- docs/custom-brokers.md | 23 ++++++++++++++++++----- docs/module-structure.md | 7 +++---- docs/troubleshooting.md | 5 ++++- 5 files changed, 30 insertions(+), 15 deletions(-) diff --git a/docs/channels-and-authorization.md b/docs/channels-and-authorization.md index e37a18e..cc7bf01 100644 --- a/docs/channels-and-authorization.md +++ b/docs/channels-and-authorization.md @@ -214,9 +214,9 @@ recognize and explicitly approve patterns. Never enable them for ordinary users merely because the corresponding exact channel would be allowed. Redis may report the same publication through overlapping exact and pattern -subscriptions. The adapter suppresses recently seen event IDs using -`redis['deduplicationCapacity']`; avoid unnecessary overlap so correctness does -not depend on a bounded deduplication window. +subscriptions. The adapter suppresses recently seen channel/event ID pairs +using `redis['deduplicationCapacity']`; avoid unnecessary overlap so +correctness does not depend on a bounded deduplication window. ## Browser authentication diff --git a/docs/configuration.md b/docs/configuration.md index ec2eddb..97f3955 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -180,7 +180,7 @@ therefore does not separate SSE traffic; `channelPrefix` is the isolation boundary. Custom brokers are added by registering a new key in `brokers`. A broker -definition must provide either `factory` or `adapter`: +definition must provide exactly one of `factory` or `adapter`: - `factory`: `BrokerAdapterFactoryInterface`, callable returning one, or class name implementing it; @@ -305,7 +305,7 @@ public array $redis = [ | `pingInterval` | `15.0` | Verify an otherwise idle subscribed socket with Redis PING. | | `reconnectAttempts` | `2` | Publisher/subscriber transport reconnect attempts. | | `reconnectDelayMilliseconds` | `250` | Delay between Redis reconnect attempts. | -| `deduplicationCapacity` | `1024` | Recent event IDs retained to suppress duplicates after reconnects or overlapping subscriptions. | +| `deduplicationCapacity` | `1024` | Recent channel/event ID pairs retained to suppress duplicates from overlapping exact and pattern subscriptions. | | `maxPayloadBytes` | `1048576` | Maximum serialized event or inbound RESP bulk string size. | | `maxResponseElements` | `1024` | Maximum elements accepted in one RESP array. | | `maxResponseDepth` | `8` | Maximum accepted RESP nesting depth. | diff --git a/docs/custom-brokers.md b/docs/custom-brokers.md index 79ccb19..4d0da86 100644 --- a/docs/custom-brokers.md +++ b/docs/custom-brokers.md @@ -135,10 +135,9 @@ For non-trivial options, mirror the built-in Redis and Mercure adapters: put a small config object and config factory in the broker folder. ```php -use Maniaba\CodeIgniterSse\Broker\AbstractBrokerConfigFactory; use Maniaba\CodeIgniterSse\Config\Sse; -final class AcmeConfigFactory extends AbstractBrokerConfigFactory +final class AcmeConfigFactory { public function create(Sse $config): AcmeConfig { @@ -150,6 +149,19 @@ final class AcmeConfigFactory extends AbstractBrokerConfigFactory token: self::nullableString($options['token'] ?? null), ); } + + private static function nullableString(mixed $value): ?string + { + return is_string($value) && $value !== '' ? $value : null; + } + + /** + * @return array + */ + private static function arrayOption(mixed $value): array + { + return is_array($value) ? $value : []; + } } ``` @@ -186,7 +198,8 @@ final readonly class AcmeBrokerAdapterFactory implements BrokerAdapterFactoryInt `BrokerBuildContext` provides the package serializer and event factory. Use the serializer when the external transport should receive the standard package -event envelope. +event envelope. The PHP stream manager encodes browser SSE payloads itself; +the serializer is for broker transport payloads. ## Implement publishing @@ -289,7 +302,6 @@ final readonly class AcmeStreamBrokerAdapterFactory implements BrokerAdapterFact $subscriber = new AcmeSubscriber(service('acmeSseClient'), $context->serializer); $manager = new SseConnectionManager( $subscriber, - $context->serializer, $context->events, SseConnectionOptions::fromConfig($config), ); @@ -333,7 +345,8 @@ Most failures map directly to a missing or wrong contract: | Error or symptom | Fix | |---|---| -| `must define either "factory" or "adapter"` | Add `factory` or `adapter` to `Sse::$brokers[$broker]`. | +| `must define either "factory" or "adapter"` | Add exactly one of `factory` or `adapter` to `Sse::$brokers[$broker]`. | +| `must not define both "factory" and "adapter"` | Remove one entry so the broker definition has a single construction path. | | `adapter factory "..." does not exist` | Check namespace, Composer autoload, and class name. Run `composer dump-autoload`. | | `factory must implement BrokerAdapterFactoryInterface` | Implement `create(Sse $config, BrokerBuildContext $context): BrokerAdapterInterface`. | | `adapter must implement BrokerAdapterInterface` | Implement `publisher()` and `subscriptionEndpoint()`. | diff --git a/docs/module-structure.md b/docs/module-structure.md index 525022b..b150e10 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -7,7 +7,6 @@ and browser behavior separate. src/ ├── Authorization/ ├── Broker/ -│ ├── AbstractBrokerConfigFactory.php │ ├── InMemory/ │ ├── Local/ │ ├── Mercure/ @@ -59,8 +58,6 @@ because browsers subscribe directly to the Hub. `Broker\InMemory` is for tests and one-process examples. `Broker\Null` is useful when applications want the API enabled without delivering live events. `Broker\Local` contains the reusable local adapter used by PHP-stream brokers. -`Broker\AbstractBrokerConfigFactory` is a shared helper for broker-specific -config factories; it is a file rather than a broker folder. Custom broker implementations should live in their own folder and enter the package through `BrokerAdapterInterface` or `BrokerAdapterFactoryInterface`. @@ -87,7 +84,9 @@ Current package streaming response `SseConnectionManager` owns the long-running stream loop. It sends retry configuration, the optional connected event, broker events, idle heartbeats, -and maximum-lifetime shutdown. +and maximum-lifetime shutdown. `BrowserEventEncoder` keeps the JSON payload +sent to browser EventSource clients separate from broker transport +serialization. ## Browser asset diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 5e2e9eb..415f07f 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -86,13 +86,16 @@ TCP, TLS, ACL, or application configuration problem. ## Custom broker is not loaded The broker entry in `Sse::$brokers` must resolve to `BrokerAdapterInterface`. -Use either: +Use exactly one of: - `factory`: a `BrokerAdapterFactoryInterface` instance, class name, or callable returning one; - `adapter`: a `BrokerAdapterInterface` instance, class name, or callable returning one. +If both keys are present, remove one so the resolver has a single construction +path. + If the error says the factory or adapter class does not exist, verify the namespace and Composer autoload, then run: From 6c4d7f580b93bf9625d6d4aefc871606c23ecb6e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 14:57:35 +0200 Subject: [PATCH 11/12] Refactor SseClient to support automatic stream resolution via JSON bootstrap response --- CHANGELOG.md | 3 +- README.md | 7 +- docs/architecture.md | 5 + docs/browser-client.md | 79 ++-- docs/channels-and-authorization.md | 10 +- docs/configuration.md | 8 +- docs/custom-brokers.md | 15 +- docs/deployment.md | 11 +- docs/index.md | 3 + docs/mercure.md | 12 +- docs/module-structure.md | 11 +- docs/quick-start.md | 9 +- docs/testing.md | 11 +- docs/troubleshooting.md | 19 +- resources/js/sse-client.d.ts | 22 +- resources/js/sse-client.js | 389 +++++++++++++----- .../Mercure/MercureSubscriptionEndpoint.php | 6 +- .../SubscriptionEndpointInterface.php | 4 + src/Endpoint/LocalSseSubscriptionEndpoint.php | 80 +++- tests/Browser/SseClient.test.mjs | 312 +++++++++++++- tests/HTTP/SseControllerTest.php | 53 ++- tests/HTTP/SubscriptionEndpointTest.php | 85 +++- 22 files changed, 915 insertions(+), 239 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index be6829d..1ff2364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,7 +13,8 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht Server-Sent Events through Redis Pub/Sub. - Channel authorization and authenticated-user resolution contracts. - Streaming response support for supported CodeIgniter releases. -- Framework-independent browser `SseClient` ES module. +- Framework-independent browser `SseClient` ES module with server-selected, + broker-neutral stream bootstrap. - Redis subscriber health PINGs, bounded reconnects, payload/RESP safety limits, and event-ID deduplication. - Mercure 0.x Hub publisher, exact topic mapping, private subscriber JWT diff --git a/README.md b/README.md index c3e83d1..e031172 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,9 @@ live.on('status', ({ status }) => { live.connect(); ``` +`SseClient` first asks `/sse` for the server-selected stream URL, then opens +EventSource. Frontend configuration is unchanged when the broker changes. + The browser's native `EventSource` automatically reconnects when a connection ends. With Redis, the package intentionally limits the PHP stream lifetime. With Mercure, the browser streams directly from the Hub. @@ -195,11 +198,13 @@ browser client connects directly to the Hub: ```javascript const live = new SseClient({ endpoint: '/sse', - transport: 'mercure', channels: [`users.${currentUserId}`], }); ``` +The client asks the package endpoint for a generic stream descriptor, so +frontend code is unchanged when the configured broker changes. + Mercure can replay retained Hub history through `Last-Event-ID`. See [Mercure Hub](docs/mercure.md) for Docker, signing keys, authorization, cookies, CORS, and reverse-proxy configuration. diff --git a/docs/architecture.md b/docs/architecture.md index bcd45ef..182e74a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,6 +44,11 @@ Application service / controller / worker Both paths use the same event envelope, channel authorizer, browser event handlers, and `sse()->publish(...)` API. +The browser begins both paths with the same short JSON request. The active +subscription endpoint returns a generic EventSource URL and query map: Redis +points back to the PHP route, while Mercure points to the Hub. Broker selection +therefore remains on the server. + ## Public application boundary Normal application code uses the high-level service: diff --git a/docs/browser-client.md b/docs/browser-client.md index cae91d0..7315eb7 100644 --- a/docs/browser-client.md +++ b/docs/browser-client.md @@ -1,7 +1,8 @@ # Browser client -`resources/js/sse-client.js` is a dependency-free ES module around the native -browser `EventSource`. +`resources/js/sse-client.js` is a dependency-free ES module that resolves the +configured stream through the package endpoint and then opens a native browser +`EventSource`. It provides: @@ -11,13 +12,15 @@ It provides: - safe JSON parsing; - channel and custom query parameters; - credential configuration; +- automatic stream resolution without exposing the configured broker; - direct Mercure Hub transport with cookie authorization; - explicit `connect()` and `close()`; - a hook for an application-defined fallback. -It deliberately does not implement a second reconnect timer. Native -`EventSource` follows the server's SSE `retry` value and reconnects -automatically. +After EventSource opens, native `EventSource` follows the server's SSE `retry` +value and reconnects automatically. Before it opens, transient bootstrap +network failures and retryable HTTP responses use a bounded exponential +backoff. ## Import @@ -90,7 +93,6 @@ const live = new SseClient({ source: 'orders-page', }, withCredentials: true, - transport: 'eventsource', fallback: null, }); ``` @@ -100,11 +102,10 @@ const live = new SseClient({ | `endpoint` | required | Absolute or browser-relative SSE URL. | | `channels` | `[]` | Unique logical channel names, sent comma-separated. | | `query` | `{}` | Object or `URLSearchParams` merged into the endpoint. | -| `withCredentials` | `true` | Passed to the native `EventSource` constructor. | -| `transport` | `eventsource` | Use `eventsource` for the PHP stream or `mercure` for Hub bootstrap. | -| `fallback` | `null` | Optional outage/unsupported-browser hook. | +| `withCredentials` | `true` | Enables cross-origin credentials for bootstrap and EventSource requests. | +| `fallback` | `null` | Optional bootstrap, connection-error, or unsupported-browser hook. | | `eventSourceFactory` | native | Test seam for supplying an EventSource-compatible object. | -| `fetchFactory` | native | Test seam for the Mercure authorization request. | +| `fetchFactory` | native | Test seam for the short stream-resolution request. | Array query values are appended as repeated parameters. `null` and `undefined` object values are omitted. The `channels` option wins over an existing @@ -112,15 +113,32 @@ object values are omitted. The `channels` option wins over an existing Do not use query parameters for bearer tokens or secrets. -## Mercure transport +## Automatic stream resolution -When Mercure is the configured broker, `endpoint` is the short CodeIgniter -authorization route rather than the Hub stream itself: +The browser does not select Redis, Mercure, or another broker. `connect()` +first requests a generic connection descriptor from `endpoint` with +`Accept: application/json`. The server decides where the EventSource should +connect. + +For a PHP stream, `url: null` tells the client to reuse the original endpoint: + +```json +{ + "url": null, + "expiresAt": null +} +``` + +For an external Hub, the response supplies its URL, query parameters, and an +optional authorization expiry. The client treats both forms identically and +does not expose a transport option. + +When Mercure is configured, `endpoint` remains the short CodeIgniter +authorization route: ```javascript const live = new SseClient({ endpoint: '/sse', - transport: 'mercure', channels: ['users.42'], withCredentials: true, }); @@ -161,9 +179,9 @@ live.setChannels(['users.42', 'orders.918']); chaining. Native `EventSource` cannot change its URL after it is opened. When channels -change on an active client, the wrapper closes the current source and opens a -new one with the updated `channels` query parameter. Removing the last channel -closes the stream. +change on an active client, the wrapper closes the current source, resolves a +new authorized stream URL, and opens it with the updated `channels` query +parameter. Removing the last channel closes the stream. ## Named events @@ -211,8 +229,8 @@ live.on('sse.error', ({ data }) => { }); ``` -Transport state should still be taken from the `status` handler. Heartbeats are -SSE comments and never dispatch a JavaScript event. +Connection state should still be taken from the `status` handler. Heartbeats +are SSE comments and never dispatch a JavaScript event. ## Message shape @@ -365,17 +383,25 @@ The hook receives: ``` Reasons are `unsupported`, `construction-error`, `connection-error`, and -`authorization-error`. The last value means the Mercure bootstrap request -failed or returned invalid data. The hook runs once per reconnect cycle; a -successful native `open` resets it. +`bootstrap-error`. The last value means the stream-resolution request failed +or returned invalid data. The hook runs once per reconnect cycle; a successful +native `open` resets it. + +Network failures and HTTP `408`, `425`, `429`, or `5xx` bootstrap responses are +retried automatically with a capped exponential delay. `close()` cancels both +an in-flight bootstrap request and a scheduled retry. Other bootstrap failures +close the client because retrying cannot repair an invalid request or response +contract. + Browsers report the server's expected finite-lifetime rotation through the same native `error` event as a network outage, so `connection-error` alone must not immediately start polling. Debounce an outage fallback and cancel it when the next `open` arrives. The `unsupported` reason is definitive and can start a fallback immediately. -If no fallback is provided and the browser has no `EventSource`, `connect()` -throws a clear error. +The client requires both `fetch` and `EventSource`. If no fallback is provided +and the browser has no `EventSource`, `connect()` throws a clear error. A +missing `fetch` is reported as `bootstrap-error`. ## Credentials and CORS @@ -387,8 +413,9 @@ const live = new SseClient({ }); ``` -For cross-origin cookies, the server must return the exact allowed origin and -`Access-Control-Allow-Credentials: true`. Cookie `Domain`, `Secure`, and +For cross-origin cookies, both the bootstrap response and EventSource response +must return the exact allowed origin and the +`Access-Control-Allow-Credentials: true` header. Cookie `Domain`, `Secure`, and `SameSite` attributes must also permit the request. Standard browser EventSource does not accept arbitrary request headers. diff --git a/docs/channels-and-authorization.md b/docs/channels-and-authorization.md index cc7bf01..077f9eb 100644 --- a/docs/channels-and-authorization.md +++ b/docs/channels-and-authorization.md @@ -133,10 +133,12 @@ filters reject unauthenticated requests early; concurrency limits prevent one user or reconnect loop from consuming an unbounded number of PHP workers. Neither replaces per-channel authorization. -With Mercure, the same route filters and authorizer protect the short -bootstrap request. The resulting subscriber JWT contains only approved -Mercure topics. The long-lived Hub connection does not occupy a PHP worker, -but rate limiting still protects token issuance and reconnect churn. +The same route filters and authorizer protect the browser's short bootstrap +request for every broker. With Redis, the following EventSource request is +authorized again before the PHP stream opens. With Mercure, the resulting +subscriber JWT contains only approved topics. The long-lived Hub connection +does not occupy a PHP worker, but rate limiting still protects token issuance +and reconnect churn. For zero-argument implementations, select both classes in the package config: diff --git a/docs/configuration.md b/docs/configuration.md index 97f3955..ffa46c5 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,7 +37,7 @@ final class Sse extends BaseSse | `route['method']` | `stream` | Controller method used by the route. | | `route['filters']` | `[]` | CI4 filter aliases applied to the route. | | `route['options']` | `[]` | Additional CI4 route options. | -| `requireAcceptHeader` | `true` | Require `Accept: text/event-stream` for the PHP stream transport. | +| `requireAcceptHeader` | `true` | Require `Accept: text/event-stream` for a PHP stream; JSON bootstrap requests remain supported. | Example: @@ -84,9 +84,9 @@ $routes->get( `SseRoutes::register()` honors `route['enabled']`, so it is intended for automatic package discovery rather than bypassing a disabled route. Keep the request -method `GET`. With Redis it is the event stream; with Mercure it is a short -authorization/bootstrap request used before the browser connects directly to -the Hub. +method `GET`. The browser first requests a broker-neutral JSON stream +descriptor. With Redis, EventSource then reconnects to the same route for the +PHP stream; with Mercure, it connects directly to the authorized Hub URL. ## Stream behavior diff --git a/docs/custom-brokers.md b/docs/custom-brokers.md index 4d0da86..8f2655f 100644 --- a/docs/custom-brokers.md +++ b/docs/custom-brokers.md @@ -233,8 +233,9 @@ query parameter. ## Implement the subscription endpoint -For Hub-style brokers, return bootstrap data that the browser client can use -to connect to the external service: +For Hub-style brokers, return the generic stream descriptor understood by the +browser client. The frontend does not need a broker name or custom transport +switch: ```php use CodeIgniter\HTTP\RequestInterface; @@ -265,9 +266,9 @@ final readonly class AcmeSubscriptionEndpoint implements return $response ->setStatusCode(200) ->setJSON([ - 'transport' => 'acme', - 'endpoint' => $this->publicEndpoint, - 'channels' => $channels, + 'url' => $this->publicEndpoint, + 'query' => ['channel' => $channels], + 'expiresAt' => null, ]) ->setHeader('Cache-Control', 'private, no-store') ->setHeader('X-Content-Type-Options', 'nosniff'); @@ -276,7 +277,9 @@ final readonly class AcmeSubscriptionEndpoint implements ``` The package authorizes channels before `respond()` is called. The endpoint -receives only approved channel selectors. +receives only approved channel selectors. `url` is the EventSource target, +`query` contains parameters merged into that URL, and `expiresAt` is either a +Unix timestamp for refreshing short-lived authorization or `null`. ## PHP-stream brokers diff --git a/docs/deployment.md b/docs/deployment.md index 475d181..fc6c273 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,11 +4,12 @@ An SSE response stays open and flushes frames incrementally. Web servers, reverse proxies, compression middleware, CDNs, and PHP worker limits must be configured for that behavior. -This page primarily describes the built-in PHP stream used with Redis. When -the Mercure broker is active, the CodeIgniter route is a short authorization -request and the Hub owns the long-lived response. PHP-FPM stream capacity, -heartbeat, and buffering requirements then apply to the Hub deployment, not -the `/sse` bootstrap route. See [Mercure Hub](mercure.md). +This page primarily describes the built-in PHP stream used with Redis. The +browser always starts with a short JSON bootstrap request. With Redis it then +opens EventSource on the same CodeIgniter route; with Mercure the Hub owns the +long-lived response. PHP-FPM stream capacity, heartbeat, and buffering +requirements then apply to the Hub deployment, not the `/sse` bootstrap route. +See [Mercure Hub](mercure.md). ## Response headers diff --git a/docs/index.md b/docs/index.md index 0aeb5c0..b301c95 100644 --- a/docs/index.md +++ b/docs/index.md @@ -62,6 +62,9 @@ live.on('notification.created', ({ data }) => { live.connect(); ``` +The endpoint selects the actual stream URL, so this frontend code stays the +same for Redis, Mercure, and compatible custom brokers. + Private channels are denied until the application supplies an authorizer. Start with the [Quick start](quick-start.md), then configure [channels and authorization](channels-and-authorization.md). diff --git a/docs/mercure.md b/docs/mercure.md index bfcd085..f8cdb8d 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -160,14 +160,15 @@ publisher decorator used for every broker. ## Browser client -Set the client transport to `mercure`: +The browser client uses the same configuration for every broker. When the +server is configured for Mercure, its generic bootstrap descriptor points the +client to the authorized Hub URL: ```javascript import { SseClient } from '@maniaba/codeigniter4-sse-browser'; const live = new SseClient({ endpoint: '/sse', - transport: 'mercure', channels: [`users.${currentUserId}`], withCredentials: true, }); @@ -191,9 +192,10 @@ CodeIgniter validates and authorizes every channel, sets an HttpOnly ```json { - "transport": "mercure", - "hub": "https://app.example.com/.well-known/mercure", - "topics": ["urn:storefront:sse:users.42"], + "url": "https://app.example.com/.well-known/mercure", + "query": { + "topic": ["urn:storefront:sse:users.42"] + }, "expiresAt": 1785520800 } ``` diff --git a/docs/module-structure.md b/docs/module-structure.md index b150e10..c611063 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -70,8 +70,10 @@ authorizes every channel, and delegates the response to the active broker adapter's subscription endpoint. `Endpoint\LocalSseSubscriptionEndpoint` is the generic PHP stream endpoint -used by local subscriber-aware brokers. Broker-specific endpoints, such as -Mercure's bootstrap endpoint, live beside their broker implementation. +used by local subscriber-aware brokers. It also provides the short JSON +descriptor that points the browser back to that stream. Broker-specific +endpoints, such as Mercure's Hub bootstrap endpoint, live beside their broker +implementation and return the same generic descriptor shape. `HTTP\SseResponseFactory` selects the output implementation at runtime: @@ -90,8 +92,9 @@ serialization. ## Browser asset -`resources/js/sse-client.js` is a dependency-free wrapper around native -`EventSource`. It is published to the host application by: +`resources/js/sse-client.js` resolves the server-selected stream URL and wraps +native `EventSource` without exposing broker selection to frontend code. It is +published to the host application by: ```bash php spark sse:install diff --git a/docs/quick-start.md b/docs/quick-start.md index 7c1de45..287bdec 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -146,7 +146,14 @@ live.on('status', ({ status }) => { live.connect(); ``` -The client requests: +The client first resolves the server-selected stream: + +```http +GET /sse?channels=users.42 +Accept: application/json +``` + +With the default Redis broker, it then opens: ```http GET /sse?channels=users.42 diff --git a/docs/testing.md b/docs/testing.md index 090ea6d..d5f79a8 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -137,7 +137,7 @@ repository's integration test and are not read by the Spark command. Feature tests should verify: - `GET /sse` route discovery; -- required `Accept: text/event-stream`; +- JSON stream bootstrap and required `Accept: text/event-stream` for direct streams; - missing, invalid, duplicate, and excessive channels; - default `public.*` access; - rejection of unauthorized private channels; @@ -152,8 +152,8 @@ Use recording implementations of `SubscriberInterface` and ## Browser client tests -`SseClient` accepts `eventSourceFactory` specifically so tests can supply a -small fake: +`SseClient` accepts `fetchFactory` and `eventSourceFactory` specifically so +tests can supply small deterministic fakes: ```javascript const source = new FakeEventSource(); @@ -161,6 +161,11 @@ const source = new FakeEventSource(); const live = new SseClient({ endpoint: 'https://example.test/sse', channels: ['public.test'], + fetchFactory: async () => ({ + ok: true, + status: 200, + json: async () => ({ url: null, expiresAt: null }), + }), eventSourceFactory: (url, options) => { expect(url).toContain('channels=public.test'); expect(options.withCredentials).toBe(true); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 415f07f..9e186cb 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,7 +20,8 @@ If the route was customized, use that path in the browser client. ## The endpoint returns 406 -The default configuration requires: +The default configuration accepts the browser client's JSON bootstrap request +or requires this header for a direct PHP stream request: ```http Accept: text/event-stream @@ -149,22 +150,24 @@ Verify: - `withCredentials` is enabled; - Hub CORS lists the exact application origin. -Inspect the authorization request in browser developer tools. The JSON must -contain the expected topics and the response must set the subscriber cookie. +Inspect the authorization request in browser developer tools. The JSON +`query.topic` array must contain the expected topics and the response must set +the subscriber cookie. The cookie is HttpOnly, so it will not appear through `document.cookie`. -## Mercure client reports authorization-error +## Browser client reports bootstrap-error -The browser client could not complete the short CodeIgniter bootstrap request. -Inspect its HTTP status: +The browser client could not resolve the EventSource URL through the short +CodeIgniter bootstrap request. Inspect its HTTP status: - `400` means the channel list is invalid; - `403` means channel policy or application CORS denied the request; -- `5xx` usually means Mercure signing configuration is incomplete; +- `5xx` means the active broker could not build its connection descriptor; - an invalid JSON shape means a proxy or custom controller replaced the package response. -This error occurs before EventSource connects to the Hub. +This error occurs before EventSource connects to the PHP stream or external +Hub. ## The connection opens but no events arrive diff --git a/resources/js/sse-client.d.ts b/resources/js/sse-client.d.ts index f499596..370a3aa 100644 --- a/resources/js/sse-client.d.ts +++ b/resources/js/sse-client.d.ts @@ -29,8 +29,6 @@ export type SseQuery = export type SseChannelInput = string | readonly string[]; -export type SseTransport = 'eventsource' | 'mercure'; - /** * Parsed message delivered to named event handlers and global message handlers. */ @@ -106,7 +104,7 @@ export type SseFallbackReason = | 'unsupported' | 'construction-error' | 'connection-error' - | 'authorization-error'; + | 'bootstrap-error'; export interface SseFallbackContext { readonly reason: SseFallbackReason; @@ -158,18 +156,12 @@ export interface SseClientOptions { readonly query?: SseQuery; /** - * Passed to native EventSource for credentialed CORS/cookie requests. + * Enables cross-origin credentials for bootstrap fetch and EventSource. */ readonly withCredentials?: boolean; /** - * `eventsource` connects directly to endpoint. `mercure` first calls - * endpoint for authorization and then connects directly to the returned Hub. - */ - readonly transport?: SseTransport; - - /** - * Optional application fallback for unsupported browsers or connection errors. + * Optional application fallback for bootstrap, connection, or browser errors. */ readonly fallback?: SseFallback | null; @@ -179,7 +171,7 @@ export interface SseClientOptions { readonly eventSourceFactory?: SseEventSourceFactory | null; /** - * Optional Fetch-compatible factory used by the Mercure authorization step. + * Optional Fetch-compatible factory used to resolve the stream endpoint. */ readonly fetchFactory?: SseFetchFactory | null; } @@ -191,7 +183,6 @@ export declare class SseClient { readonly channels: string[]; readonly query: SseQuery; readonly withCredentials: boolean; - readonly transport: SseTransport; /** * Current lifecycle status. @@ -270,9 +261,8 @@ export declare class SseClient { unsubscribe(channels: SseChannelInput): this; /** - * Open the EventSource connection. With Mercure, authorization completes - * asynchronously before EventSource is opened. Repeated calls are idempotent - * while active. + * Resolve and open the EventSource connection asynchronously. Repeated + * calls are idempotent while active. */ connect(): this; diff --git a/resources/js/sse-client.js b/resources/js/sse-client.js index 03624a8..e46ec0b 100644 --- a/resources/js/sse-client.js +++ b/resources/js/sse-client.js @@ -19,6 +19,14 @@ const GLOBAL_MESSAGE_EVENT = 'message'; const STATUS_EVENT = 'status'; const RESERVED_NATIVE_EVENTS = new Set(['open', 'error']); +class BootstrapRequestError extends Error { + constructor(message, retryable, cause = null) { + super(message, cause === null ? undefined : { cause }); + this.name = 'BootstrapRequestError'; + this.retryable = retryable; + } +} + /** * @typedef {Object} SseMessage * @property {string|null} id @@ -39,7 +47,6 @@ const RESERVED_NATIVE_EVENTS = new Set(['open', 'error']); * @property {string[]} [channels] * @property {Object|URLSearchParams} [query] * @property {boolean} [withCredentials] - * @property {'eventsource'|'mercure'} [transport] * @property {Function|null} [fallback] * @property {Function|null} [eventSourceFactory] * @property {Function|null} [fetchFactory] @@ -54,7 +61,6 @@ export class SseClient { channels = [], query = {}, withCredentials = true, - transport = 'eventsource', fallback = null, eventSourceFactory = null, fetchFactory = null, @@ -82,12 +88,6 @@ export class SseClient { throw new TypeError('SseClient fallback must be a function or null.'); } - if (!['eventsource', 'mercure'].includes(transport)) { - throw new TypeError( - 'SseClient transport must be "eventsource" or "mercure".', - ); - } - if ( eventSourceFactory !== null && typeof eventSourceFactory !== 'function' @@ -107,7 +107,6 @@ export class SseClient { this.channels = this._normalizeChannels(channels); this.query = query; this.withCredentials = Boolean(withCredentials); - this.transport = transport; this._queryIsUrlSearchParams = queryIsUrlSearchParams; this._fallback = fallback; @@ -122,6 +121,9 @@ export class SseClient { this._fallbackInvoked = false; this._connectionGeneration = 0; this._refreshTimer = null; + this._bootstrapController = null; + this._bootstrapRetryTimer = null; + this._bootstrapRetryAttempt = 0; this._handleOpen = this._handleOpen.bind(this); this._handleError = this._handleError.bind(this); @@ -306,12 +308,26 @@ export class SseClient { return this; } + this._startConnection(); + + return this; + } + + _startConnection(isRetry = false) { + this._abortBootstrap(); + this._clearBootstrapRetryTimer(); + if (this._source !== null) { this._teardownSource(); } this._manuallyClosed = false; - this._fallbackInvoked = false; + + if (!isRetry) { + this._fallbackInvoked = false; + this._bootstrapRetryAttempt = 0; + } + const generation = ++this._connectionGeneration; const factory = this._resolveEventSourceFactory(); @@ -335,21 +351,20 @@ export class SseClient { ); } - return this; + return; } this._currentUrl = this._buildUrl(); this._setStatus(SseClientStatus.CONNECTING); - if (this.transport === 'mercure') { - this._connectMercure(factory, generation); - - return this; + if ( + generation !== this._connectionGeneration + || this._manuallyClosed + ) { + return; } - this._openSource(factory, this._currentUrl); - - return this; + this._connectThroughBootstrap(factory, generation); } /** @@ -494,49 +509,42 @@ export class SseClient { return globalThis.fetch.bind(globalThis); } + _createAbortController() { + if ( + typeof globalThis === 'undefined' + || typeof globalThis.AbortController !== 'function' + ) { + return null; + } + + return new globalThis.AbortController(); + } + /** * @param {Function} eventSourceFactory * @param {number} generation */ - _connectMercure(eventSourceFactory, generation) { + _connectThroughBootstrap(eventSourceFactory, generation) { const fetchFactory = this._resolveFetchFactory(); if (fetchFactory === null) { - this._handleMercureAuthorizationError( - new Error('Fetch is required by the Mercure transport.'), + this._handleBootstrapError( + new Error('Fetch is required to resolve the SSE stream.'), generation, + false, ); return; } - Promise.resolve(fetchFactory(this._currentUrl, { - method: 'GET', - headers: { - Accept: 'application/json', - }, - credentials: this.withCredentials ? 'include' : 'same-origin', - cache: 'no-store', - })) - .then(async (response) => { - if ( - response === null - || typeof response !== 'object' - || typeof response.json !== 'function' - ) { - throw new TypeError( - 'The Mercure authorization endpoint returned an invalid response.', - ); - } + const controller = this._createAbortController(); + this._bootstrapController = controller; - if (response.ok !== true) { - throw new Error( - `Mercure authorization failed with HTTP ${response.status ?? 0}.`, - ); - } - - return response.json(); - }) + Promise.resolve(this._requestBootstrap( + fetchFactory, + this._currentUrl, + controller?.signal, + )) .then((bootstrap) => { if ( generation !== this._connectionGeneration @@ -545,90 +553,184 @@ export class SseClient { return; } - const authorization = this._normalizeMercureBootstrap(bootstrap); - const hubUrl = this._buildMercureHubUrl( - authorization.hub, - authorization.topics, + const connection = this._normalizeBootstrap(bootstrap); + const streamUrl = this._buildStreamUrl( + connection.url, + connection.query, ); - this._currentUrl = hubUrl; + this._bootstrapRetryAttempt = 0; + this._currentUrl = streamUrl; const opened = this._openSource( eventSourceFactory, - hubUrl, + streamUrl, false, ); if (opened) { - this._scheduleMercureRefresh( - authorization.expiresAt, + this._scheduleRefresh( + connection.expiresAt, generation, ); } }) .catch((error) => { - this._handleMercureAuthorizationError(error, generation); + this._handleBootstrapError( + error, + generation, + error instanceof BootstrapRequestError && error.retryable, + ); + }) + .finally(() => { + if (this._bootstrapController === controller) { + this._bootstrapController = null; + } }); } + async _requestBootstrap(fetchFactory, url, signal) { + let response; + + try { + response = await fetchFactory(url, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + credentials: this.withCredentials ? 'include' : 'same-origin', + cache: 'no-store', + ...(signal === undefined ? {} : { signal }), + }); + } catch (error) { + throw new BootstrapRequestError( + 'The SSE bootstrap request failed.', + true, + error, + ); + } + + if ( + response === null + || typeof response !== 'object' + || typeof response.json !== 'function' + ) { + throw new BootstrapRequestError( + 'The SSE bootstrap endpoint returned an invalid response.', + false, + ); + } + + if (response.ok !== true) { + const status = Number.isInteger(response.status) + ? response.status + : 0; + + throw new BootstrapRequestError( + `SSE bootstrap failed with HTTP ${status}.`, + status === 0 + || status === 408 + || status === 425 + || status === 429 + || status >= 500, + ); + } + + try { + return await response.json(); + } catch (error) { + throw new BootstrapRequestError( + 'The SSE bootstrap endpoint returned invalid JSON.', + false, + error, + ); + } + } + /** * @param {*} bootstrap - * @returns {{hub: string, topics: string[], expiresAt: number|null}} + * @returns {{url: string, query: Object, expiresAt: number|null}} */ - _normalizeMercureBootstrap(bootstrap) { + _normalizeBootstrap(bootstrap) { + const hasUrl = ( + bootstrap !== null + && typeof bootstrap === 'object' + && Object.prototype.hasOwnProperty.call(bootstrap, 'url') + ); + const query = bootstrap?.query ?? {}; + const expiresAt = bootstrap?.expiresAt ?? null; + if ( - bootstrap === null - || typeof bootstrap !== 'object' - || bootstrap.transport !== 'mercure' - || typeof bootstrap.hub !== 'string' - || bootstrap.hub.trim() === '' - || !Array.isArray(bootstrap.topics) - || bootstrap.topics.length === 0 - || bootstrap.topics.some((topic) => ( - typeof topic !== 'string' || topic.trim() === '' - )) + !hasUrl || ( - bootstrap.expiresAt !== null - && bootstrap.expiresAt !== undefined - && !Number.isInteger(bootstrap.expiresAt) + bootstrap.url !== null + && ( + typeof bootstrap.url !== 'string' + || bootstrap.url.trim() === '' + ) + ) + || query === null + || typeof query !== 'object' + || Array.isArray(query) + || Object.values(query).some((value) => !this._isQueryValue(value)) + || ( + expiresAt !== null + && ( + !Number.isSafeInteger(expiresAt) + || expiresAt <= Math.floor(Date.now() / 1000) + ) ) ) { throw new TypeError( - 'The Mercure authorization endpoint returned invalid bootstrap data.', + 'The SSE bootstrap endpoint returned invalid connection data.', ); } return { - hub: bootstrap.hub.trim(), - topics: [...new Set( - bootstrap.topics.map((topic) => topic.trim()), - )], - expiresAt: Number.isInteger(bootstrap.expiresAt) - ? bootstrap.expiresAt - : null, + url: bootstrap.url === null + ? this._currentUrl + : bootstrap.url.trim(), + query, + expiresAt, }; } /** - * @param {string} hub - * @param {string[]} topics + * @param {string} endpoint + * @param {Object} query * @returns {string} */ - _buildMercureHubUrl(hub, topics) { + _buildStreamUrl(endpoint, query) { let url; try { - url = new URL(hub); + url = new URL(endpoint, this._currentUrl); } catch (error) { throw new TypeError( - 'The Mercure Hub URL must be absolute.', + 'The SSE bootstrap endpoint returned an invalid stream URL.', { cause: error }, ); } - url.searchParams.delete('topic'); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new TypeError( + 'The SSE stream URL must use HTTP or HTTPS.', + ); + } + + for (const [name, value] of Object.entries(query)) { + if (value === null || value === undefined) { + continue; + } + + url.searchParams.delete(name); - for (const topic of topics) { - url.searchParams.append('topic', topic); + if (Array.isArray(value)) { + for (const item of value) { + url.searchParams.append(name, String(item)); + } + } else { + url.searchParams.set(name, String(value)); + } } url.hash = ''; @@ -640,14 +742,17 @@ export class SseClient { * @param {number|null} expiresAt * @param {number} generation */ - _scheduleMercureRefresh(expiresAt, generation) { + _scheduleRefresh(expiresAt, generation) { this._clearRefreshTimer(); if (expiresAt === null) { return; } - const delay = Math.max(1000, (expiresAt * 1000) - Date.now() - 30000); + const delay = Math.min( + 2_147_483_647, + Math.max(1000, (expiresAt * 1000) - Date.now() - 30000), + ); this._refreshTimer = setTimeout(() => { if ( @@ -657,8 +762,7 @@ export class SseClient { return; } - this._teardownSource(); - this.connect(); + this._startConnection(); }, delay); this._refreshTimer?.unref?.(); @@ -668,7 +772,7 @@ export class SseClient { * @param {*} error * @param {number} generation */ - _handleMercureAuthorizationError(error, generation) { + _handleBootstrapError(error, generation, retryable) { if ( generation !== this._connectionGeneration || this._manuallyClosed @@ -677,13 +781,61 @@ export class SseClient { } this._source = null; + + if (retryable) { + this._bootstrapRetryAttempt++; + this._setStatus(SseClientStatus.RECONNECTING, { + reason: 'bootstrap-error', + error, + }); + + const handled = this._invokeFallback( + 'bootstrap-error', + null, + error, + ); + + if ( + generation !== this._connectionGeneration + || this._manuallyClosed + ) { + return; + } + + const delay = Math.min( + 30000, + 1000 * (2 ** Math.min(this._bootstrapRetryAttempt - 1, 5)), + ); + + this._bootstrapRetryTimer = setTimeout(() => { + this._bootstrapRetryTimer = null; + + if ( + generation !== this._connectionGeneration + || this._manuallyClosed + ) { + return; + } + + this._startConnection(true); + }, delay); + + this._bootstrapRetryTimer?.unref?.(); + + if (!handled && this._bootstrapRetryAttempt === 1) { + this._reportHandlerError(error); + } + + return; + } + this._setStatus(SseClientStatus.CLOSED, { - reason: 'authorization-error', + reason: 'bootstrap-error', error, }); const handled = this._invokeFallback( - 'authorization-error', + 'bootstrap-error', null, error, ); @@ -700,10 +852,8 @@ export class SseClient { return ( ( this._source !== null - || ( - this.transport === 'mercure' - && this._status === SseClientStatus.CONNECTING - ) + || this._status === SseClientStatus.CONNECTING + || this._bootstrapRetryTimer !== null ) && this._status !== SseClientStatus.CLOSED && this._status !== SseClientStatus.UNSUPPORTED @@ -715,10 +865,9 @@ export class SseClient { return; } - this._connectionGeneration++; - this._teardownSource(); - if (this.channels.length === 0) { + this._connectionGeneration++; + this._teardownSource(); this._setStatus(SseClientStatus.CLOSED, { reason: 'channels-empty', }); @@ -726,7 +875,7 @@ export class SseClient { return; } - this.connect(); + this._startConnection(); } /** @@ -785,6 +934,26 @@ export class SseClient { return url.toString(); } + /** + * @param {*} value + * @returns {boolean} + */ + _isQueryValue(value) { + if (Array.isArray(value)) { + return value.every((item) => ( + !Array.isArray(item) && this._isQueryValue(item) + )); + } + + return ( + value === null + || value === undefined + || typeof value === 'string' + || typeof value === 'boolean' + || (typeof value === 'number' && Number.isFinite(value)) + ); + } + /** * @param {string|string[]} channels * @returns {string[]} @@ -1028,6 +1197,8 @@ export class SseClient { } _teardownSource() { + this._abortBootstrap(); + this._clearBootstrapRetryTimer(); this._clearRefreshTimer(); if (this._source === null) { @@ -1061,6 +1232,20 @@ export class SseClient { this._refreshTimer = null; } + _abortBootstrap() { + this._bootstrapController?.abort(); + this._bootstrapController = null; + } + + _clearBootstrapRetryTimer() { + if (this._bootstrapRetryTimer === null) { + return; + } + + clearTimeout(this._bootstrapRetryTimer); + this._bootstrapRetryTimer = null; + } + /** * @param {string} eventName * @param {*} payload diff --git a/src/Broker/Mercure/MercureSubscriptionEndpoint.php b/src/Broker/Mercure/MercureSubscriptionEndpoint.php index 3bc66b3..4e15710 100644 --- a/src/Broker/Mercure/MercureSubscriptionEndpoint.php +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -40,13 +40,13 @@ public function respond( $response = $response ->setStatusCode(200) ->setJSON([ - 'transport' => 'mercure', - 'hub' => $subscription->hubUrl, - 'topics' => $subscription->topics, + 'url' => $subscription->hubUrl, + 'query' => ['topic' => $subscription->topics], 'expiresAt' => $subscription->expiresAt, ]) ->setHeader('Cache-Control', 'private, no-store') ->setHeader('Link', sprintf('<%s>; rel="mercure"', $subscription->hubUrl)) + ->appendHeader('Vary', 'Accept') ->setHeader('X-Content-Type-Options', 'nosniff'); if ($subscription->token !== null) { diff --git a/src/Contracts/SubscriptionEndpointInterface.php b/src/Contracts/SubscriptionEndpointInterface.php index def6301..6430bd3 100644 --- a/src/Contracts/SubscriptionEndpointInterface.php +++ b/src/Contracts/SubscriptionEndpointInterface.php @@ -10,6 +10,10 @@ interface SubscriptionEndpointInterface { /** + * Browser bootstrap responses contain url (null reuses the request URL), + * optional query parameters, and optional expiresAt. PHP stream endpoints + * may instead return a streaming response for text/event-stream. + * * @param list $channels */ public function respond( diff --git a/src/Endpoint/LocalSseSubscriptionEndpoint.php b/src/Endpoint/LocalSseSubscriptionEndpoint.php index 443d108..d78f0b8 100644 --- a/src/Endpoint/LocalSseSubscriptionEndpoint.php +++ b/src/Endpoint/LocalSseSubscriptionEndpoint.php @@ -31,7 +31,7 @@ public function channelSelectorValidator(): ChannelSelectorValidatorInterface public function preflight(RequestInterface $request, ResponseInterface $response): ?ResponseInterface { - if (! $this->requireAcceptHeader || $this->acceptsEventStream($request)) { + if ($this->requestedRepresentation($request) !== null || ! $this->requireAcceptHeader) { return null; } @@ -39,7 +39,7 @@ public function preflight(RequestInterface $request, ResponseInterface $response $response, 406, 'not_acceptable', - 'This endpoint requires Accept: text/event-stream.', + 'This endpoint requires Accept: text/event-stream or application/json.', ); } @@ -48,6 +48,18 @@ public function respond( ResponseInterface $response, array $channels, ): ResponseInterface { + if ($this->requestedRepresentation($request) === 'bootstrap') { + return $response + ->setStatusCode(200) + ->setJSON([ + 'url' => null, + 'expiresAt' => null, + ]) + ->setHeader('Cache-Control', 'private, no-store') + ->appendHeader('Vary', 'Accept') + ->setHeader('X-Content-Type-Options', 'nosniff'); + } + $factory = $this->responseFactory ?? new SseResponseFactory($response); $response = $factory->create( @@ -55,49 +67,81 @@ function (SseOutputInterface $output) use ($channels): void { $this->manager->stream($output, $channels); }, ); + $response->appendHeader('Vary', 'Accept'); $response->setHeader('X-Content-Type-Options', 'nosniff'); return $response; } - private function acceptsEventStream(RequestInterface $request): bool + private function requestedRepresentation(RequestInterface $request): ?string { $accept = strtolower($request->getHeaderLine('Accept')); if ($accept === '') { - return false; + return null; } - $quality = 0.0; - $specificity = -1; + $bootstrapFound = false; + $bootstrapQuality = 0.0; + $bootstrapOrder = PHP_INT_MAX; + $streamQuality = 0.0; + $streamSpecificity = -1; + $streamOrder = PHP_INT_MAX; - foreach (explode(',', $accept) as $mediaRange) { + foreach (explode(',', $accept) as $order => $mediaRange) { $mediaRange = trim($mediaRange); if ($mediaRange === '') { continue; } - [$mediaType, $rangeQuality, $rangeSpecificity] = $this->parseAcceptRange($mediaRange); + [$mediaType, $rangeQuality] = $this->parseAcceptRange($mediaRange); if ( - ($mediaType !== 'text/event-stream' && $mediaType !== 'text/*' && $mediaType !== '*/*') - || $rangeSpecificity < $specificity + $mediaType === 'application/json' + && (! $bootstrapFound || $rangeQuality > $bootstrapQuality) ) { - continue; + $bootstrapFound = true; + $bootstrapQuality = $rangeQuality; + $bootstrapOrder = $order; } - if ($rangeSpecificity > $specificity || $rangeQuality > $quality) { - $quality = $rangeQuality; - $specificity = $rangeSpecificity; + $rangeSpecificity = $this->eventStreamSpecificity($mediaType); + + if ( + $rangeSpecificity > $streamSpecificity + || ($rangeSpecificity === $streamSpecificity && $rangeQuality > $streamQuality) + ) { + $streamQuality = $rangeQuality; + $streamSpecificity = $rangeSpecificity; + $streamOrder = $order; } } - return $specificity >= 0 && $quality > 0.0; + $bootstrapAccepted = $bootstrapFound && $bootstrapQuality > 0.0; + $streamAccepted = $streamSpecificity >= 0 && $streamQuality > 0.0; + + if (! $bootstrapAccepted) { + return $streamAccepted ? 'stream' : null; + } + + if (! $streamAccepted) { + return 'bootstrap'; + } + + if ($bootstrapQuality !== $streamQuality) { + return $bootstrapQuality > $streamQuality ? 'bootstrap' : 'stream'; + } + + if ($streamSpecificity !== 2) { + return $streamSpecificity < 2 ? 'bootstrap' : 'stream'; + } + + return $bootstrapOrder < $streamOrder ? 'bootstrap' : 'stream'; } /** - * @return array{0: string, 1: float, 2: int} + * @return array{0: string, 1: float} */ private function parseAcceptRange(string $mediaRange): array { @@ -115,7 +159,7 @@ private function parseAcceptRange(string $mediaRange): array break; } - return [$mediaType, $quality, $this->specificity($mediaType)]; + return [$mediaType, $quality]; } private function parseQuality(string $value): float @@ -127,7 +171,7 @@ private function parseQuality(string $value): float return (float) $value; } - private function specificity(string $mediaType): int + private function eventStreamSpecificity(string $mediaType): int { return match ($mediaType) { 'text/event-stream' => 2, diff --git a/tests/Browser/SseClient.test.mjs b/tests/Browser/SseClient.test.mjs index 79864ba..b4699a1 100644 --- a/tests/Browser/SseClient.test.mjs +++ b/tests/Browser/SseClient.test.mjs @@ -35,7 +35,18 @@ class FakeEventSource { } } -test('builds the URL, dispatches envelopes, reports status, and closes', () => { +const directBootstrap = async () => ({ + ok: true, + status: 200, + json: async () => ({ url: null, expiresAt: null }), +}); + +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); +const wait = (milliseconds) => new Promise((resolve) => { + setTimeout(resolve, milliseconds); +}); + +test('builds the URL, dispatches envelopes, reports status, and closes', async () => { const source = new FakeEventSource(); const statuses = []; const named = []; @@ -47,6 +58,7 @@ test('builds the URL, dispatches envelopes, reports status, and closes', () => { endpoint: 'https://example.test/sse?locale=bs', channels: ['users.42', 'orders.918', 'users.42'], query: { tenant: 7 }, + fetchFactory: directBootstrap, eventSourceFactory: (url, options) => { receivedUrl = url; receivedOptions = options; @@ -61,6 +73,9 @@ test('builds the URL, dispatches envelopes, reports status, and closes', () => { .onMessage((message) => globalMessages.push(message)) .connect(); + await nextTurn(); + + assert.equal(Object.hasOwn(client, 'transport'), false); const url = new URL(receivedUrl); assert.equal(url.searchParams.get('channels'), 'users.42,orders.918'); assert.equal(url.searchParams.get('tenant'), '7'); @@ -104,15 +119,17 @@ test('builds the URL, dispatches envelopes, reports status, and closes', () => { ]); }); -test('preserves invalid JSON and invokes unsupported fallback once', () => { +test('preserves invalid JSON and invokes unsupported fallback once', async () => { const source = new FakeEventSource(); const messages = []; const client = new SseClient({ endpoint: 'https://example.test/sse', + fetchFactory: directBootstrap, eventSourceFactory: () => source, }); client.onMessage((message) => messages.push(message)).connect(); + await nextTurn(); source.dispatch('message', { data: 'not-json', lastEventId: '' }); assert.equal(messages[0].parsed, false); @@ -133,7 +150,7 @@ test('preserves invalid JSON and invokes unsupported fallback once', () => { assert.equal(fallbackCalls, 1); }); -test('subscribes and unsubscribes channels by reconnecting active sources', () => { +test('subscribes and unsubscribes channels by reconnecting active sources', async () => { const sources = []; const urls = []; const statuses = []; @@ -141,6 +158,7 @@ test('subscribes and unsubscribes channels by reconnecting active sources', () = const client = new SseClient({ endpoint: 'https://example.test/sse', channels: ['public.news'], + fetchFactory: directBootstrap, eventSourceFactory: (url) => { const source = new FakeEventSource(); @@ -152,10 +170,12 @@ test('subscribes and unsubscribes channels by reconnecting active sources', () = }); client.on('status', ({ status }) => statuses.push(status)).connect(); + await nextTurn(); sources[0].readyState = 1; sources[0].dispatch('open', { type: 'open' }); client.subscribe(['users.42', 'public.news']); + await nextTurn(); assert.equal(sources.length, 2); assert.equal(sources[0].closed, true); @@ -169,6 +189,7 @@ test('subscribes and unsubscribes channels by reconnecting active sources', () = sources[1].dispatch('open', { type: 'open' }); client.unsubscribe('public.news'); + await nextTurn(); assert.equal(sources.length, 3); assert.equal(sources[1].closed, true); @@ -190,7 +211,225 @@ test('subscribes and unsubscribes channels by reconnecting active sources', () = ]); }); -test('authorizes Mercure channels and opens EventSource directly on the Hub', async () => { +test('restarts an in-flight bootstrap when channels change', async () => { + const bootstrapUrls = []; + const signals = []; + const sourceUrls = []; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + channels: ['public.news'], + fetchFactory: async (url, { signal }) => { + bootstrapUrls.push(url); + signals.push(signal); + + if (bootstrapUrls.length === 1) { + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + } + + return directBootstrap(); + }, + eventSourceFactory: (url) => { + sourceUrls.push(url); + + return new FakeEventSource(); + }, + }); + + client.connect(); + client.subscribe('users.42'); + await nextTurn(); + + assert.equal(bootstrapUrls.length, 2); + assert.equal(signals[0].aborted, true); + assert.equal(sourceUrls.length, 1); + assert.equal( + new URL(sourceUrls[0]).searchParams.get('channels'), + 'public.news,users.42', + ); + + client.close(); +}); + +test('close aborts an in-flight bootstrap without opening EventSource', async () => { + let bootstrapSignal; + let eventSourceCalls = 0; + let fallbackCalls = 0; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + fetchFactory: async (url, { signal }) => { + bootstrapSignal = signal; + + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + }, + eventSourceFactory: () => { + eventSourceCalls++; + + return new FakeEventSource(); + }, + fallback: () => { + fallbackCalls++; + }, + }); + + client.connect(); + client.close(); + await nextTurn(); + + assert.equal(bootstrapSignal.aborted, true); + assert.equal(eventSourceCalls, 0); + assert.equal(fallbackCalls, 0); + assert.equal(client.status, SseClientStatus.CLOSED); +}); + +test('retries a transient bootstrap failure', async () => { + let bootstrapCalls = 0; + const sources = []; + const fallbackReasons = []; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + channels: ['public.news'], + fetchFactory: async () => { + bootstrapCalls++; + + if (bootstrapCalls === 1) { + throw new Error('temporary network failure'); + } + + return directBootstrap(); + }, + eventSourceFactory: () => { + const source = new FakeEventSource(); + sources.push(source); + + return source; + }, + fallback: ({ reason }) => fallbackReasons.push(reason), + }); + + client.connect(); + await nextTurn(); + + assert.equal(client.status, SseClientStatus.RECONNECTING); + assert.equal(bootstrapCalls, 1); + + await wait(1100); + await nextTurn(); + + assert.equal(bootstrapCalls, 2); + assert.equal(sources.length, 1); + assert.equal(client.status, SseClientStatus.CONNECTING); + assert.deepEqual(fallbackReasons, ['bootstrap-error']); + + client.close(); +}); + +test('refreshes an expiring stream even before EventSource opens', async () => { + let bootstrapCalls = 0; + const sources = []; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + channels: ['public.news'], + fetchFactory: async () => { + bootstrapCalls++; + + return { + ok: true, + status: 200, + json: async () => ({ + url: null, + expiresAt: Math.floor(Date.now() / 1000) + 30, + }), + }; + }, + eventSourceFactory: () => { + const source = new FakeEventSource(); + sources.push(source); + + return source; + }, + }); + + client.connect(); + await nextTurn(); + await wait(1100); + await nextTurn(); + + assert.equal(bootstrapCalls, 2); + assert.equal(sources.length, 2); + assert.equal(sources[0].closed, true); + assert.equal(client.status, SseClientStatus.CONNECTING); + + client.close(); +}); + +test('rejects expired bootstrap authorization without opening EventSource', async () => { + let eventSourceCalls = 0; + const fallbackReasons = []; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + channels: ['public.news'], + fetchFactory: async () => ({ + ok: true, + status: 200, + json: async () => ({ + url: null, + expiresAt: Math.floor(Date.now() / 1000) - 1, + }), + }), + eventSourceFactory: () => { + eventSourceCalls++; + + return new FakeEventSource(); + }, + fallback: ({ reason }) => fallbackReasons.push(reason), + }); + + client.connect(); + await nextTurn(); + + assert.equal(eventSourceCalls, 0); + assert.equal(client.status, SseClientStatus.CLOSED); + assert.deepEqual(fallbackReasons, ['bootstrap-error']); +}); + +test('clamps long-lived stream refresh timers', async () => { + let bootstrapCalls = 0; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + channels: ['public.news'], + fetchFactory: async () => { + bootstrapCalls++; + + return { + ok: true, + status: 200, + json: async () => ({ + url: null, + expiresAt: Number.MAX_SAFE_INTEGER, + }), + }; + }, + eventSourceFactory: () => new FakeEventSource(), + }); + + client.connect(); + await nextTurn(); + await wait(20); + + assert.equal(bootstrapCalls, 1); + + client.close(); +}); + +test('uses the server bootstrap to open EventSource directly on the Hub', async () => { const source = new FakeEventSource(); const fetchCalls = []; let receivedHubUrl; @@ -198,7 +437,6 @@ test('authorizes Mercure channels and opens EventSource directly on the Hub', as const client = new SseClient({ endpoint: 'https://app.example.test/sse', - transport: 'mercure', channels: ['users.42', 'projects.7'], fetchFactory: async (url, options) => { fetchCalls.push({ url, options }); @@ -207,12 +445,13 @@ test('authorizes Mercure channels and opens EventSource directly on the Hub', as ok: true, status: 200, json: async () => ({ - transport: 'mercure', - hub: 'https://hub.example.test/.well-known/mercure?custom=1', - topics: [ - 'urn:example:sse:users.42', - 'urn:example:sse:projects.7', - ], + url: 'https://hub.example.test/.well-known/mercure?custom=1', + query: { + topic: [ + 'urn:example:sse:users.42', + 'urn:example:sse:projects.7', + ], + }, expiresAt: null, }), }; @@ -227,7 +466,7 @@ test('authorizes Mercure channels and opens EventSource directly on the Hub', as client.connect(); client.connect(); - await new Promise((resolve) => setImmediate(resolve)); + await nextTurn(); assert.equal(fetchCalls.length, 1); assert.equal( @@ -255,12 +494,11 @@ test('authorizes Mercure channels and opens EventSource directly on the Hub', as client.close(); }); -test('reports Mercure authorization failures without opening EventSource', async () => { +test('reports bootstrap failures without opening EventSource', async () => { let eventSourceCalls = 0; const fallbackReasons = []; const client = new SseClient({ endpoint: 'https://app.example.test/sse', - transport: 'mercure', channels: ['users.42'], fetchFactory: async () => ({ ok: false, @@ -276,26 +514,56 @@ test('reports Mercure authorization failures without opening EventSource', async }); client.connect(); - await new Promise((resolve) => setImmediate(resolve)); + await nextTurn(); + + assert.equal(eventSourceCalls, 0); + assert.equal(client.status, SseClientStatus.CLOSED); + assert.deepEqual(fallbackReasons, ['bootstrap-error']); +}); + +test('rejects invalid bootstrap connection data', async () => { + let eventSourceCalls = 0; + const fallbackReasons = []; + const client = new SseClient({ + endpoint: 'https://app.example.test/sse', + channels: ['users.42'], + fetchFactory: async () => ({ + ok: true, + status: 200, + json: async () => ({ + url: 'javascript:alert(1)', + expiresAt: null, + }), + }), + eventSourceFactory: () => { + eventSourceCalls++; + + return new FakeEventSource(); + }, + fallback: ({ reason }) => fallbackReasons.push(reason), + }); + + client.connect(); + await nextTurn(); assert.equal(eventSourceCalls, 0); assert.equal(client.status, SseClientStatus.CLOSED); - assert.deepEqual(fallbackReasons, ['authorization-error']); + assert.deepEqual(fallbackReasons, ['bootstrap-error']); }); -test('keeps Mercure EventSource construction errors distinct from authorization', async () => { +test('keeps EventSource construction errors distinct from bootstrap errors', async () => { const fallbackReasons = []; const client = new SseClient({ endpoint: 'https://app.example.test/sse', - transport: 'mercure', channels: ['users.42'], fetchFactory: async () => ({ ok: true, status: 200, json: async () => ({ - transport: 'mercure', - hub: 'https://hub.example.test/.well-known/mercure', - topics: ['urn:example:sse:users.42'], + url: 'https://hub.example.test/.well-known/mercure', + query: { + topic: ['urn:example:sse:users.42'], + }, expiresAt: null, }), }), @@ -306,7 +574,7 @@ test('keeps Mercure EventSource construction errors distinct from authorization' }); client.connect(); - await new Promise((resolve) => setImmediate(resolve)); + await nextTurn(); assert.equal(client.status, SseClientStatus.CLOSED); assert.deepEqual(fallbackReasons, ['construction-error']); diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 4370fa4..16859ff 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -28,9 +28,9 @@ */ final class SseControllerTest extends CIUnitTestCase { - public function testRequiresEventStreamAcceptHeader(): void + public function testRejectsUnsupportedAcceptHeader(): void { - $result = $this->controllerResponse(null, 'application/json'); + $result = $this->controllerResponse(null, 'text/html'); $body = $result->getBody(); $this->assertSame(406, $result->getStatusCode()); @@ -38,6 +38,41 @@ public function testRequiresEventStreamAcceptHeader(): void $this->assertStringContainsString('not_acceptable', $body); } + public function testLocalRouteReturnsBrowserBootstrapWithoutOpeningAPhpStream(): void + { + $manager = new SseConnectionManager( + new RecordingSubscriber(), + new EventFactory(new FixedEventIdGenerator('connected-id')), + ); + $superglobals = service('superglobals'); + $this->assertInstanceOf(Superglobals::class, $superglobals); + $previousGet = $superglobals->getGetArray(); + $superglobals->setGetArray(['channels' => 'public.news']); + + try { + $result = $this->controllerResponse( + null, + 'application/json', + new BasicBrokerAdapter( + endpoint: new LocalSseSubscriptionEndpoint($manager), + ), + ); + $body = $result->getBody(); + + $this->assertSame(200, $result->getStatusCode()); + $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); + $this->assertIsString($body); + $this->assertSame( + ['url' => null, 'expiresAt' => null], + json_decode($body, true, 512, JSON_THROW_ON_ERROR), + ); + } finally { + $superglobals->setGetArray($previousGet); + } + } + public function testRejectsUnknownOriginBeforeContentNegotiation(): void { $result = $this->controllerResponse('https://attacker.example.com', null); @@ -146,10 +181,18 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo ); $this->assertIsString($body); $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $this->assertSame('mercure', $decoded['transport']); $this->assertSame( - ['urn:example:sse:public.news', 'urn:example:sse:public.status'], - $decoded['topics'], + 'https://example.test/.well-known/mercure', + $decoded['url'], + ); + $this->assertSame( + [ + 'topic' => [ + 'urn:example:sse:public.news', + 'urn:example:sse:public.status', + ], + ], + $decoded['query'], ); $this->assertIsInt($decoded['expiresAt']); diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index 101b9f2..eaaf8c4 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -60,13 +60,82 @@ public static function provideLocalEndpointAcceptsEventStreamCompatibleRequests( yield 'event stream with parameters' => ['text/event-stream; charset=utf-8', true]; - yield 'text wildcard' => ['application/json, text/*;q=0.5', true]; + yield 'text wildcard' => ['text/*;q=0.5', true]; - yield 'wildcard' => ['application/json, */*', true]; + yield 'wildcard' => ['*/*', true]; yield 'accept header disabled' => [null, false]; } + public function testLocalEndpointReturnsBrowserBootstrapForJsonRequests(): void + { + [$request, $response] = $this->http('application/json'); + $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); + + $this->assertNull($endpoint->preflight($request, $response)); + + $result = $endpoint->respond($request, $response, ['public.news']); + $body = $result->getBody(); + + $this->assertSame(200, $result->getStatusCode()); + $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); + $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); + $this->assertIsString($body); + $this->assertSame( + ['url' => null, 'expiresAt' => null], + json_decode($body, true, 512, JSON_THROW_ON_ERROR), + ); + } + + #[DataProvider('provideLocalEndpointHonorsPreferredRepresentation')] + public function testLocalEndpointHonorsPreferredRepresentation( + string $accept, + bool $expectsBootstrap, + ): void { + [$request, $response] = $this->http($accept); + $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); + + $this->assertNull($endpoint->preflight($request, $response)); + + $result = $endpoint->respond($request, $response, ['public.news']); + + if ($expectsBootstrap) { + $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); + + return; + } + + $this->assertInstanceOf(LegacySseResponse::class, $result); + } + + /** + * @return iterable + */ + public static function provideLocalEndpointHonorsPreferredRepresentation(): iterable + { + yield 'stream has higher quality' => [ + 'application/json;q=0.1, text/event-stream;q=1', + false, + ]; + + yield 'bootstrap has higher quality' => [ + 'application/json;q=1, text/event-stream;q=0.1', + true, + ]; + + yield 'stream wins equal quality by order' => [ + 'text/event-stream, application/json', + false, + ]; + + yield 'bootstrap wins equal quality by order' => [ + 'application/json, text/event-stream', + true, + ]; + } + #[DataProvider('provideLocalEndpointRejectsUnacceptableEventStreamRequests')] public function testLocalEndpointRejectsUnacceptableEventStreamRequests(string $accept): void { @@ -88,6 +157,8 @@ public static function provideLocalEndpointRejectsUnacceptableEventStreamRequest yield 'wildcard q zero' => ['*/*;q=0']; + yield 'bootstrap q zero' => ['application/json;q=0']; + yield 'specific q zero wins over wildcard' => ['text/event-stream;q=0, */*;q=1']; yield 'text wildcard q zero wins over wildcard' => ['text/*;q=0, */*;q=1']; @@ -101,6 +172,7 @@ public function testLocalEndpointCreatesStreamingResponse(): void $result = $endpoint->respond($request, $response, ['public.news']); $this->assertInstanceOf(LegacySseResponse::class, $result); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); $result->pretend(); @@ -146,12 +218,15 @@ public function testMercureEndpointReturnsBootstrapPayloadAndCookie(): void $result->getHeaderLine('Link'), ); $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); $this->assertIsString($body); $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $this->assertSame('mercure', $decoded['transport']); - $this->assertSame('https://example.test/.well-known/mercure', $decoded['hub']); - $this->assertSame(['urn:example:sse:public.news'], $decoded['topics']); + $this->assertSame('https://example.test/.well-known/mercure', $decoded['url']); + $this->assertSame( + ['topic' => ['urn:example:sse:public.news']], + $decoded['query'], + ); $this->assertIsInt($decoded['expiresAt']); $cookie = $result->getCookie('mercureAuthorization'); From eec2e08bfab5e32d27d81ea883f4ac23daef9969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Amel=20Junuzovi=C4=87?= Date: Sat, 1 Aug 2026 15:30:15 +0200 Subject: [PATCH 12/12] Added `DirectSseAdapter`, `InMemorySseAdapter`, `LocalSseAdapter`, `MercureSseAdapter`, and `RedisSseAdapter` for handling different SSE connection strategies. --- README.md | 20 +- docs/architecture.md | 8 +- docs/browser-client.md | 111 ++-- docs/channels-and-authorization.md | 17 +- docs/configuration.md | 8 +- docs/custom-brokers.md | 42 +- docs/deployment.md | 10 +- docs/examples.md | 6 + docs/index.md | 11 +- docs/installation.md | 5 + docs/mercure.md | 26 +- docs/module-structure.md | 8 +- docs/quick-start.md | 15 +- docs/testing.md | 17 +- docs/troubleshooting.md | 19 +- package.json | 27 + resources/Config/Sse.php | 2 +- resources/js/adapters/direct-sse-adapter.d.ts | 12 + resources/js/adapters/direct-sse-adapter.js | 14 + .../js/adapters/in-memory-sse-adapter.d.ts | 6 + .../js/adapters/in-memory-sse-adapter.js | 6 + resources/js/adapters/local-sse-adapter.d.ts | 6 + resources/js/adapters/local-sse-adapter.js | 6 + .../js/adapters/mercure-sse-adapter.d.ts | 14 + resources/js/adapters/mercure-sse-adapter.js | 227 +++++++ resources/js/adapters/redis-sse-adapter.d.ts | 6 + resources/js/adapters/redis-sse-adapter.js | 6 + resources/js/sse-client.d.ts | 87 ++- resources/js/sse-client.js | 610 +++++++----------- .../Mercure/MercureSubscriptionEndpoint.php | 36 +- src/Commands/InstallCommand.php | 17 + .../SubscriptionEndpointInterface.php | 4 - src/Endpoint/LocalSseSubscriptionEndpoint.php | 136 +--- src/HTTP/AcceptHeaderNegotiator.php | 183 ++++++ tests/Browser/SseClient.test.mjs | 430 ++++-------- tests/Browser/package-export.test.mjs | 6 + tests/HTTP/SseControllerTest.php | 20 +- tests/HTTP/SubscriptionEndpointTest.php | 152 ++--- 38 files changed, 1301 insertions(+), 1035 deletions(-) create mode 100644 resources/js/adapters/direct-sse-adapter.d.ts create mode 100644 resources/js/adapters/direct-sse-adapter.js create mode 100644 resources/js/adapters/in-memory-sse-adapter.d.ts create mode 100644 resources/js/adapters/in-memory-sse-adapter.js create mode 100644 resources/js/adapters/local-sse-adapter.d.ts create mode 100644 resources/js/adapters/local-sse-adapter.js create mode 100644 resources/js/adapters/mercure-sse-adapter.d.ts create mode 100644 resources/js/adapters/mercure-sse-adapter.js create mode 100644 resources/js/adapters/redis-sse-adapter.d.ts create mode 100644 resources/js/adapters/redis-sse-adapter.js create mode 100644 src/HTTP/AcceptHeaderNegotiator.php diff --git a/README.md b/README.md index e031172..d56799b 100644 --- a/README.md +++ b/README.md @@ -76,10 +76,14 @@ Accept: text/event-stream Use the included framework-independent ES module: ```javascript -import { SseClient } from '/vendor/codeigniter4-sse/sse-client.js'; +import { + RedisSseAdapter, + SseClient, +} from '/vendor/codeigniter4-sse/sse-client.js'; const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: [`users.${currentUserId}`], withCredentials: true, }); @@ -96,8 +100,8 @@ live.on('status', ({ status }) => { live.connect(); ``` -`SseClient` first asks `/sse` for the server-selected stream URL, then opens -EventSource. Frontend configuration is unchanged when the broker changes. +`SseClient` opens EventSource through the selected frontend adapter. When the +server broker changes, update the adapter class in the browser client. The browser's native `EventSource` automatically reconnects when a connection ends. With Redis, the package intentionally limits the PHP stream lifetime. @@ -196,14 +200,20 @@ authorization request that sets a topic-scoped HttpOnly JWT cookie, and the browser client connects directly to the Hub: ```javascript +import { + MercureSseAdapter, + SseClient, +} from '/vendor/codeigniter4-sse/sse-client.js'; + const live = new SseClient({ endpoint: '/sse', + adapter: new MercureSseAdapter(), channels: [`users.${currentUserId}`], }); ``` -The client asks the package endpoint for a generic stream descriptor, so -frontend code is unchanged when the configured broker changes. +Use the frontend adapter that matches the configured broker. Mercure's adapter +authorizes through the package route and then opens EventSource on the Hub. Mercure can replay retained Hub history through `Last-Event-ID`. See [Mercure Hub](docs/mercure.md) for Docker, signing keys, authorization, diff --git a/docs/architecture.md b/docs/architecture.md index 182e74a..3477a25 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -44,10 +44,10 @@ Application service / controller / worker Both paths use the same event envelope, channel authorizer, browser event handlers, and `sse()->publish(...)` API. -The browser begins both paths with the same short JSON request. The active -subscription endpoint returns a generic EventSource URL and query map: Redis -points back to the PHP route, while Mercure points to the Hub. Broker selection -therefore remains on the server. +The browser uses a frontend adapter that matches the configured broker. Redis, +local, and in-memory adapters open EventSource directly on the CodeIgniter +route. Mercure first calls the same route for topic authorization and then +opens EventSource on the Hub. ## Public application boundary diff --git a/docs/browser-client.md b/docs/browser-client.md index 7315eb7..c8444e0 100644 --- a/docs/browser-client.md +++ b/docs/browser-client.md @@ -1,8 +1,8 @@ # Browser client -`resources/js/sse-client.js` is a dependency-free ES module that resolves the -configured stream through the package endpoint and then opens a native browser -`EventSource`. +`resources/js/sse-client.js` is a dependency-free ES module that wraps native +browser `EventSource`. Broker-specific stream resolution lives in small +adapter classes. It provides: @@ -12,15 +12,12 @@ It provides: - safe JSON parsing; - channel and custom query parameters; - credential configuration; -- automatic stream resolution without exposing the configured broker; -- direct Mercure Hub transport with cookie authorization; +- explicit frontend adapters for Redis, Mercure, local, and in-memory brokers; - explicit `connect()` and `close()`; - a hook for an application-defined fallback. After EventSource opens, native `EventSource` follows the server's SSE `retry` -value and reconnects automatically. Before it opens, transient bootstrap -network failures and retryable HTTP responses use a bounded exponential -backoff. +value and reconnects automatically. ## Import @@ -35,6 +32,7 @@ Then import it through the package export: ```javascript import { + RedisSseAdapter, SseClient, SseClientStatus, } from '@maniaba/codeigniter4-sse-browser'; @@ -62,6 +60,7 @@ published package assets: ```javascript import { + RedisSseAdapter, SseClient, SseClientStatus, } from '/vendor/codeigniter4-sse/sse-client.js'; @@ -77,16 +76,18 @@ The package ships TypeScript declarations next to the module: ```text resources/js/sse-client.d.ts +resources/js/adapters/*.d.ts ``` -When `php spark sse:install` publishes browser assets, it copies both -`sse-client.js` and `sse-client.d.ts`. +When `php spark sse:install` publishes browser assets, it copies +`sse-client.js`, `sse-client.d.ts`, and the adapter files. ## Constructor ```javascript const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: ['users.42', 'orders.918'], query: { locale: document.documentElement.lang, @@ -100,12 +101,12 @@ const live = new SseClient({ | Option | Default | Description | |---|---|---| | `endpoint` | required | Absolute or browser-relative SSE URL. | +| `adapter` | `new DirectSseAdapter()` | Object that resolves the final EventSource URL. | | `channels` | `[]` | Unique logical channel names, sent comma-separated. | | `query` | `{}` | Object or `URLSearchParams` merged into the endpoint. | -| `withCredentials` | `true` | Enables cross-origin credentials for bootstrap and EventSource requests. | -| `fallback` | `null` | Optional bootstrap, connection-error, or unsupported-browser hook. | +| `withCredentials` | `true` | Enables cross-origin credentials for EventSource and adapter requests. | +| `fallback` | `null` | Optional adapter-error, connection-error, or unsupported-browser hook. | | `eventSourceFactory` | native | Test seam for supplying an EventSource-compatible object. | -| `fetchFactory` | native | Test seam for the short stream-resolution request. | Array query values are appended as repeated parameters. `null` and `undefined` object values are omitted. The `channels` option wins over an existing @@ -113,46 +114,64 @@ object values are omitted. The `channels` option wins over an existing Do not use query parameters for bearer tokens or secrets. -## Automatic stream resolution +## Adapters -The browser does not select Redis, Mercure, or another broker. `connect()` -first requests a generic connection descriptor from `endpoint` with -`Accept: application/json`. The server decides where the EventSource should -connect. +Choose the adapter that matches the configured server broker: -For a PHP stream, `url: null` tells the client to reuse the original endpoint: +```javascript +import { + RedisSseAdapter, + SseClient, +} from '@maniaba/codeigniter4-sse-browser'; -```json -{ - "url": null, - "expiresAt": null -} +const live = new SseClient({ + endpoint: '/sse', + adapter: new RedisSseAdapter(), + channels: ['users.42'], +}); ``` -For an external Hub, the response supplies its URL, query parameters, and an -optional authorization expiry. The client treats both forms identically and -does not expose a transport option. +`RedisSseAdapter`, `LocalSseAdapter`, and `InMemorySseAdapter` are semantic +direct adapters. They open EventSource against `endpoint` after `SseClient` +adds channels and query parameters. -When Mercure is configured, `endpoint` remains the short CodeIgniter -authorization route: +Mercure uses an authorization step before EventSource opens: ```javascript +import { + MercureSseAdapter, + SseClient, +} from '@maniaba/codeigniter4-sse-browser'; + const live = new SseClient({ endpoint: '/sse', + adapter: new MercureSseAdapter(), channels: ['users.42'], withCredentials: true, }); ``` -`connect()` fetches authorized Hub topics, receives the subscriber JWT through -an HttpOnly cookie, then opens EventSource directly against the returned Hub -URL. The authorization request is asynchronous; observe `status` when the UI -needs to know when the Hub connection reaches `open`. +`MercureSseAdapter` calls the CodeIgniter endpoint with +`Accept: application/json`, receives `{ hub, topics, expiresAt }`, then opens +EventSource directly against the Hub URL with repeated `topic` parameters. The client refreshes a time-limited Mercure authorization before it expires. Channel changes request a new token scoped to the new topic list. See [Mercure Hub](mercure.md) for server, cookie, CORS, and reverse-proxy setup. +Custom adapters implement `resolve()` and may implement `cancel()`: + +```javascript +const adapter = { + resolve({ url }) { + return { url, expiresAt: null }; + }, + cancel() { + // Optional: abort an in-flight async resolve. + }, +}; +``` + ## Channels Initial channels are passed to the constructor: @@ -160,6 +179,7 @@ Initial channels are passed to the constructor: ```javascript const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: ['users.42'], }); ``` @@ -179,8 +199,8 @@ live.setChannels(['users.42', 'orders.918']); chaining. Native `EventSource` cannot change its URL after it is opened. When channels -change on an active client, the wrapper closes the current source, resolves a -new authorized stream URL, and opens it with the updated `channels` query +change on an active client, the wrapper closes the current source, asks the +adapter for a new connection, and opens it with the updated `channels` query parameter. Removing the last channel closes the stream. ## Named events @@ -351,6 +371,7 @@ fallback when EventSource is unavailable or a connection reports an error: ```javascript const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: ['public.status'], fallback: ({ reason, client }) => { if (reason === 'unsupported') { @@ -383,38 +404,32 @@ The hook receives: ``` Reasons are `unsupported`, `construction-error`, `connection-error`, and -`bootstrap-error`. The last value means the stream-resolution request failed -or returned invalid data. The hook runs once per reconnect cycle; a successful +`adapter-error`. The last value means the selected adapter failed or returned +invalid connection data. The hook runs once per reconnect cycle; a successful native `open` resets it. -Network failures and HTTP `408`, `425`, `429`, or `5xx` bootstrap responses are -retried automatically with a capped exponential delay. `close()` cancels both -an in-flight bootstrap request and a scheduled retry. Other bootstrap failures -close the client because retrying cannot repair an invalid request or response -contract. - Browsers report the server's expected finite-lifetime rotation through the same native `error` event as a network outage, so `connection-error` alone must not immediately start polling. Debounce an outage fallback and cancel it when the next `open` arrives. The `unsupported` reason is definitive and can start a fallback immediately. -The client requires both `fetch` and `EventSource`. If no fallback is provided -and the browser has no `EventSource`, `connect()` throws a clear error. A -missing `fetch` is reported as `bootstrap-error`. +Direct adapters require only `EventSource`. `MercureSseAdapter` also requires +`fetch`; missing `fetch` is reported as `adapter-error`. ## Credentials and CORS ```javascript const live = new SseClient({ endpoint: 'https://api.example.com/sse', + adapter: new RedisSseAdapter(), channels: ['users.42'], withCredentials: true, }); ``` -For cross-origin cookies, both the bootstrap response and EventSource response -must return the exact allowed origin and the +For cross-origin cookies, adapter requests and EventSource responses must +return the exact allowed origin and the `Access-Control-Allow-Credentials: true` header. Cookie `Domain`, `Secure`, and `SameSite` attributes must also permit the request. diff --git a/docs/channels-and-authorization.md b/docs/channels-and-authorization.md index 077f9eb..76e6e5f 100644 --- a/docs/channels-and-authorization.md +++ b/docs/channels-and-authorization.md @@ -133,12 +133,11 @@ filters reject unauthenticated requests early; concurrency limits prevent one user or reconnect loop from consuming an unbounded number of PHP workers. Neither replaces per-channel authorization. -The same route filters and authorizer protect the browser's short bootstrap -request for every broker. With Redis, the following EventSource request is -authorized again before the PHP stream opens. With Mercure, the resulting -subscriber JWT contains only approved topics. The long-lived Hub connection -does not occupy a PHP worker, but rate limiting still protects token issuance -and reconnect churn. +The same route filters and authorizer protect direct EventSource requests and +Mercure authorization requests. With Redis, authorization happens before the +PHP stream opens. With Mercure, the resulting subscriber JWT contains only +approved topics. The long-lived Hub connection does not occupy a PHP worker, +but rate limiting still protects token issuance and reconnect churn. For zero-argument implementations, select both classes in the package config: @@ -228,8 +227,14 @@ provide a standard way to set arbitrary authorization headers. Prefer same-site secure session cookies: ```javascript +import { + RedisSseAdapter, + SseClient, +} from '/vendor/codeigniter4-sse/sse-client.js'; + new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: ['users.42'], withCredentials: true, }); diff --git a/docs/configuration.md b/docs/configuration.md index ffa46c5..8019414 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -37,7 +37,7 @@ final class Sse extends BaseSse | `route['method']` | `stream` | Controller method used by the route. | | `route['filters']` | `[]` | CI4 filter aliases applied to the route. | | `route['options']` | `[]` | Additional CI4 route options. | -| `requireAcceptHeader` | `true` | Require `Accept: text/event-stream` for a PHP stream; JSON bootstrap requests remain supported. | +| `requireAcceptHeader` | `true` | Require `Accept: text/event-stream` for PHP-streamed brokers. | Example: @@ -84,9 +84,9 @@ $routes->get( `SseRoutes::register()` honors `route['enabled']`, so it is intended for automatic package discovery rather than bypassing a disabled route. Keep the request -method `GET`. The browser first requests a broker-neutral JSON stream -descriptor. With Redis, EventSource then reconnects to the same route for the -PHP stream; with Mercure, it connects directly to the authorized Hub URL. +method `GET`. Direct frontend adapters open EventSource on this route. The +Mercure frontend adapter first requests JSON authorization from this route and +then connects directly to the authorized Hub URL. ## Stream behavior diff --git a/docs/custom-brokers.md b/docs/custom-brokers.md index 8f2655f..0577c4b 100644 --- a/docs/custom-brokers.md +++ b/docs/custom-brokers.md @@ -233,9 +233,9 @@ query parameter. ## Implement the subscription endpoint -For Hub-style brokers, return the generic stream descriptor understood by the -browser client. The frontend does not need a broker name or custom transport -switch: +For Hub-style brokers, return the authorization payload expected by that +broker's frontend adapter. The core browser client does not hard-code custom +Hub payloads. ```php use CodeIgniter\HTTP\RequestInterface; @@ -266,8 +266,8 @@ final readonly class AcmeSubscriptionEndpoint implements return $response ->setStatusCode(200) ->setJSON([ - 'url' => $this->publicEndpoint, - 'query' => ['channel' => $channels], + 'endpoint' => $this->publicEndpoint, + 'channels' => $channels, 'expiresAt' => null, ]) ->setHeader('Cache-Control', 'private, no-store') @@ -277,9 +277,35 @@ final readonly class AcmeSubscriptionEndpoint implements ``` The package authorizes channels before `respond()` is called. The endpoint -receives only approved channel selectors. `url` is the EventSource target, -`query` contains parameters merged into that URL, and `expiresAt` is either a -Unix timestamp for refreshing short-lived authorization or `null`. +receives only approved channel selectors. Build EventSource targets only from +trusted broker configuration, never from unchecked request input, because the +browser opens that HTTP(S) target with the configured credential policy. + +The matching frontend adapter can translate the broker payload into the +standard `{ url, expiresAt }` connection object used by `SseClient`: + +```javascript +class AcmeSseAdapter { + async resolve({ url, withCredentials }) { + const response = await fetch(url, { + headers: { Accept: 'application/json' }, + credentials: withCredentials ? 'include' : 'same-origin', + cache: 'no-store', + }); + const payload = await response.json(); + const stream = new URL(payload.endpoint); + + for (const channel of payload.channels) { + stream.searchParams.append('channel', channel); + } + + return { + url: stream.toString(), + expiresAt: payload.expiresAt ?? null, + }; + } +} +``` ## PHP-stream brokers diff --git a/docs/deployment.md b/docs/deployment.md index fc6c273..8ef7863 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -4,12 +4,12 @@ An SSE response stays open and flushes frames incrementally. Web servers, reverse proxies, compression middleware, CDNs, and PHP worker limits must be configured for that behavior. -This page primarily describes the built-in PHP stream used with Redis. The -browser always starts with a short JSON bootstrap request. With Redis it then -opens EventSource on the same CodeIgniter route; with Mercure the Hub owns the +This page primarily describes the built-in PHP stream used with Redis. Direct +frontend adapters open EventSource on the CodeIgniter route. With Mercure, the +CodeIgniter route is only a short authorization request and the Hub owns the long-lived response. PHP-FPM stream capacity, heartbeat, and buffering -requirements then apply to the Hub deployment, not the `/sse` bootstrap route. -See [Mercure Hub](mercure.md). +requirements then apply to the Hub deployment, not the `/sse` authorization +route. See [Mercure Hub](mercure.md). ## Response headers diff --git a/docs/examples.md b/docs/examples.md index cfce24a..05a87ae 100644 --- a/docs/examples.md +++ b/docs/examples.md @@ -21,8 +21,14 @@ sse()->publish( Listen in the browser: ```javascript +import { + RedisSseAdapter, + SseClient, +} from '/vendor/codeigniter4-sse/sse-client.js'; + const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: [`users.${currentUserId}`], }); diff --git a/docs/index.md b/docs/index.md index b301c95..7ee7967 100644 --- a/docs/index.md +++ b/docs/index.md @@ -48,10 +48,14 @@ sse()->publish( Subscribe: ```javascript -import { SseClient } from '/vendor/codeigniter4-sse/sse-client.js'; +import { + RedisSseAdapter, + SseClient, +} from '/vendor/codeigniter4-sse/sse-client.js'; const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: [`users.${currentUserId}`], }); @@ -62,8 +66,9 @@ live.on('notification.created', ({ data }) => { live.connect(); ``` -The endpoint selects the actual stream URL, so this frontend code stays the -same for Redis, Mercure, and compatible custom brokers. +Use the frontend adapter that matches the configured broker. Redis, local, and +in-memory adapters connect directly to the package route; Mercure authorizes +through the route and then connects to the Hub. Private channels are denied until the application supplies an authorizer. Start with the [Quick start](quick-start.md), then configure diff --git a/docs/installation.md b/docs/installation.md index f4f8ad3..a9e393b 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -37,6 +37,8 @@ The command creates: app/Config/Sse.php public/vendor/codeigniter4-sse/sse-client.js public/vendor/codeigniter4-sse/sse-client.d.ts +public/vendor/codeigniter4-sse/adapters/*.js +public/vendor/codeigniter4-sse/adapters/*.d.ts ``` Existing files are skipped. Use `--force` only when they should be replaced, @@ -135,6 +137,7 @@ Without npm, the installer publishes the source ES module from: ```text vendor/maniaba/codeigniter4-sse/resources/js/sse-client.js +vendor/maniaba/codeigniter4-sse/resources/js/adapters/ ``` The default public import is: @@ -150,6 +153,8 @@ cp vendor/maniaba/codeigniter4-sse/resources/js/sse-client.js \ public/assets/sse-client.js cp vendor/maniaba/codeigniter4-sse/resources/js/sse-client.d.ts \ public/assets/sse-client.d.ts +cp -R vendor/maniaba/codeigniter4-sse/resources/js/adapters \ + public/assets/adapters ``` Then import it: diff --git a/docs/mercure.md b/docs/mercure.md index f8cdb8d..6f29506 100644 --- a/docs/mercure.md +++ b/docs/mercure.md @@ -13,8 +13,8 @@ Browser └─ EventSource ───────────────────► Mercure Hub ``` -The `/sse` CodeIgniter route is a short authorization/bootstrap request in -this mode. It does not emit an event stream and does not reserve a PHP worker. +The `/sse` CodeIgniter route is a short authorization request in this mode. It +does not emit an event stream and does not reserve a PHP worker. The Hub owns heartbeats, reconnects, history, and the live SSE response. The adapter targets the stable Mercure 0.x protocol used by Mercure 0.24.2: @@ -160,15 +160,17 @@ publisher decorator used for every broker. ## Browser client -The browser client uses the same configuration for every broker. When the -server is configured for Mercure, its generic bootstrap descriptor points the -client to the authorized Hub URL: +Use `MercureSseAdapter` when the server broker is configured for Mercure: ```javascript -import { SseClient } from '@maniaba/codeigniter4-sse-browser'; +import { + MercureSseAdapter, + SseClient, +} from '@maniaba/codeigniter4-sse-browser'; const live = new SseClient({ endpoint: '/sse', + adapter: new MercureSseAdapter(), channels: [`users.${currentUserId}`], withCredentials: true, }); @@ -192,10 +194,8 @@ CodeIgniter validates and authorizes every channel, sets an HttpOnly ```json { - "url": "https://app.example.com/.well-known/mercure", - "query": { - "topic": ["urn:storefront:sse:users.42"] - }, + "hub": "https://app.example.com/.well-known/mercure", + "topics": ["urn:storefront:sse:users.42"], "expiresAt": 1785520800 } ``` @@ -206,6 +206,10 @@ channel. It refreshes authorization and reconnects shortly before the token expires. Calling `subscribe()`, `unsubscribe()`, or `setChannels()` obtains a new token restricted to the new topic list. +Use one `SseClient` per page and combine its channels. Mercure authorization is +stored in one cookie, so separate clients on the same cookie scope can replace +each other's exact-topic authorization during authorization or reconnect. + ## Authorization rules Mercure authorization adds a second enforcement layer; it does not replace the @@ -220,7 +224,7 @@ application policy: Keep `private = true` for user, tenant, order, and project data. Setting `private = false` publishes updates publicly at the Hub even if CodeIgniter -protected the bootstrap route. +protected the authorization route. For a completely public Hub, both of these must be intentional: diff --git a/docs/module-structure.md b/docs/module-structure.md index c611063..b7d2d02 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -72,7 +72,7 @@ adapter's subscription endpoint. `Endpoint\LocalSseSubscriptionEndpoint` is the generic PHP stream endpoint used by local subscriber-aware brokers. It also provides the short JSON descriptor that points the browser back to that stream. Broker-specific -endpoints, such as Mercure's Hub bootstrap endpoint, live beside their broker +endpoints, such as Mercure's Hub authorization endpoint, live beside their broker implementation and return the same generic descriptor shape. `HTTP\SseResponseFactory` selects the output implementation at runtime: @@ -92,9 +92,9 @@ serialization. ## Browser asset -`resources/js/sse-client.js` resolves the server-selected stream URL and wraps -native `EventSource` without exposing broker selection to frontend code. It is -published to the host application by: +`resources/js/sse-client.js` wraps native `EventSource`; the files under +`resources/js/adapters/` handle broker-specific connection resolution. They +are published to the host application by: ```bash php spark sse:install diff --git a/docs/quick-start.md b/docs/quick-start.md index 287bdec..32bd5de 100644 --- a/docs/quick-start.md +++ b/docs/quick-start.md @@ -126,10 +126,14 @@ provides the channel, event name or event object, and payload. ## 4. Connect from the browser ```javascript -import { SseClient } from '/vendor/codeigniter4-sse/sse-client.js'; +import { + RedisSseAdapter, + SseClient, +} from '/vendor/codeigniter4-sse/sse-client.js'; const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: [`users.${currentUserId}`], withCredentials: true, }); @@ -146,14 +150,7 @@ live.on('status', ({ status }) => { live.connect(); ``` -The client first resolves the server-selected stream: - -```http -GET /sse?channels=users.42 -Accept: application/json -``` - -With the default Redis broker, it then opens: +With the default Redis broker, the browser opens: ```http GET /sse?channels=users.42 diff --git a/docs/testing.md b/docs/testing.md index d5f79a8..6e4c727 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -137,7 +137,7 @@ repository's integration test and are not read by the Spark command. Feature tests should verify: - `GET /sse` route discovery; -- JSON stream bootstrap and required `Accept: text/event-stream` for direct streams; +- required `Accept: text/event-stream` for direct streams and JSON Mercure authorization; - missing, invalid, duplicate, and excessive channels; - default `public.*` access; - rejection of unauthorized private channels; @@ -152,20 +152,21 @@ Use recording implementations of `SubscriberInterface` and ## Browser client tests -`SseClient` accepts `fetchFactory` and `eventSourceFactory` specifically so -tests can supply small deterministic fakes: +`SseClient` accepts `eventSourceFactory`, and `MercureSseAdapter` accepts +`fetchFactory`, so tests can supply small deterministic fakes: ```javascript +import { + RedisSseAdapter, + SseClient, +} from '@maniaba/codeigniter4-sse-browser'; + const source = new FakeEventSource(); const live = new SseClient({ endpoint: 'https://example.test/sse', + adapter: new RedisSseAdapter(), channels: ['public.test'], - fetchFactory: async () => ({ - ok: true, - status: 200, - json: async () => ({ url: null, expiresAt: null }), - }), eventSourceFactory: (url, options) => { expect(url).toContain('channels=public.test'); expect(options.withCredentials).toBe(true); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index 9e186cb..c475fe1 100644 --- a/docs/troubleshooting.md +++ b/docs/troubleshooting.md @@ -20,8 +20,8 @@ If the route was customized, use that path in the browser client. ## The endpoint returns 406 -The default configuration accepts the browser client's JSON bootstrap request -or requires this header for a direct PHP stream request: +The default Redis configuration requires this header for a direct PHP stream +request: ```http Accept: text/event-stream @@ -151,23 +151,22 @@ Verify: - Hub CORS lists the exact application origin. Inspect the authorization request in browser developer tools. The JSON -`query.topic` array must contain the expected topics and the response must set -the subscriber cookie. +`topics` array must contain the expected topics and the response must set the +subscriber cookie. The cookie is HttpOnly, so it will not appear through `document.cookie`. -## Browser client reports bootstrap-error +## Browser client reports adapter-error -The browser client could not resolve the EventSource URL through the short -CodeIgniter bootstrap request. Inspect its HTTP status: +The selected frontend adapter could not resolve the EventSource URL. For +Mercure, inspect the short CodeIgniter authorization request: - `400` means the channel list is invalid; - `403` means channel policy or application CORS denied the request; -- `5xx` means the active broker could not build its connection descriptor; +- `5xx` means the active broker could not build its authorization response; - an invalid JSON shape means a proxy or custom controller replaced the package response. -This error occurs before EventSource connects to the PHP stream or external -Hub. +This error occurs before EventSource connects to the external Hub. ## The connection opens but no events arrive diff --git a/package.json b/package.json index 44ce4a4..f318241 100644 --- a/package.json +++ b/package.json @@ -39,10 +39,37 @@ "import": "./resources/js/sse-client.js", "default": "./resources/js/sse-client.js" }, + "./adapters/direct-sse-adapter.js": { + "types": "./resources/js/adapters/direct-sse-adapter.d.ts", + "import": "./resources/js/adapters/direct-sse-adapter.js", + "default": "./resources/js/adapters/direct-sse-adapter.js" + }, + "./adapters/local-sse-adapter.js": { + "types": "./resources/js/adapters/local-sse-adapter.d.ts", + "import": "./resources/js/adapters/local-sse-adapter.js", + "default": "./resources/js/adapters/local-sse-adapter.js" + }, + "./adapters/redis-sse-adapter.js": { + "types": "./resources/js/adapters/redis-sse-adapter.d.ts", + "import": "./resources/js/adapters/redis-sse-adapter.js", + "default": "./resources/js/adapters/redis-sse-adapter.js" + }, + "./adapters/in-memory-sse-adapter.js": { + "types": "./resources/js/adapters/in-memory-sse-adapter.d.ts", + "import": "./resources/js/adapters/in-memory-sse-adapter.js", + "default": "./resources/js/adapters/in-memory-sse-adapter.js" + }, + "./adapters/mercure-sse-adapter.js": { + "types": "./resources/js/adapters/mercure-sse-adapter.d.ts", + "import": "./resources/js/adapters/mercure-sse-adapter.js", + "default": "./resources/js/adapters/mercure-sse-adapter.js" + }, "./package.json": "./package.json" }, "files": [ "resources/js/sse-client.js", + "resources/js/adapters/*.js", + "resources/js/adapters/*.d.ts", "resources/js/sse-client.d.ts", "README.md", "LICENSE.md", diff --git a/resources/Config/Sse.php b/resources/Config/Sse.php index e6303af..5e4eab1 100644 --- a/resources/Config/Sse.php +++ b/resources/Config/Sse.php @@ -16,7 +16,7 @@ class Sse extends BaseSse * * GET /sse?channels=public.news * - * Redis returns the SSE stream. Mercure returns short-lived Hub bootstrap + * Redis returns the SSE stream. Mercure returns short-lived Hub * authorization for the browser client. * * @var array diff --git a/resources/js/adapters/direct-sse-adapter.d.ts b/resources/js/adapters/direct-sse-adapter.d.ts new file mode 100644 index 0000000..f41f3d9 --- /dev/null +++ b/resources/js/adapters/direct-sse-adapter.d.ts @@ -0,0 +1,12 @@ +import type { + SseAdapter, + SseAdapterConnection, + SseAdapterContext, +} from '../sse-client.js'; + +export declare class DirectSseAdapter implements SseAdapter { + resolve(context: SseAdapterContext): SseAdapterConnection; + cancel(): void; +} + +export default DirectSseAdapter; diff --git a/resources/js/adapters/direct-sse-adapter.js b/resources/js/adapters/direct-sse-adapter.js new file mode 100644 index 0000000..869328e --- /dev/null +++ b/resources/js/adapters/direct-sse-adapter.js @@ -0,0 +1,14 @@ +export class DirectSseAdapter { + /** + * @param {{url: string}} context + * @returns {{url: string, expiresAt: null}} + */ + resolve({ url }) { + return { url, expiresAt: null }; + } + + cancel() { + } +} + +export default DirectSseAdapter; diff --git a/resources/js/adapters/in-memory-sse-adapter.d.ts b/resources/js/adapters/in-memory-sse-adapter.d.ts new file mode 100644 index 0000000..080e13c --- /dev/null +++ b/resources/js/adapters/in-memory-sse-adapter.d.ts @@ -0,0 +1,6 @@ +import { DirectSseAdapter } from './direct-sse-adapter.js'; + +export declare class InMemorySseAdapter extends DirectSseAdapter { +} + +export default InMemorySseAdapter; diff --git a/resources/js/adapters/in-memory-sse-adapter.js b/resources/js/adapters/in-memory-sse-adapter.js new file mode 100644 index 0000000..b0f474f --- /dev/null +++ b/resources/js/adapters/in-memory-sse-adapter.js @@ -0,0 +1,6 @@ +import { DirectSseAdapter } from './direct-sse-adapter.js'; + +export class InMemorySseAdapter extends DirectSseAdapter { +} + +export default InMemorySseAdapter; diff --git a/resources/js/adapters/local-sse-adapter.d.ts b/resources/js/adapters/local-sse-adapter.d.ts new file mode 100644 index 0000000..303fad0 --- /dev/null +++ b/resources/js/adapters/local-sse-adapter.d.ts @@ -0,0 +1,6 @@ +import { DirectSseAdapter } from './direct-sse-adapter.js'; + +export declare class LocalSseAdapter extends DirectSseAdapter { +} + +export default LocalSseAdapter; diff --git a/resources/js/adapters/local-sse-adapter.js b/resources/js/adapters/local-sse-adapter.js new file mode 100644 index 0000000..1fc5870 --- /dev/null +++ b/resources/js/adapters/local-sse-adapter.js @@ -0,0 +1,6 @@ +import { DirectSseAdapter } from './direct-sse-adapter.js'; + +export class LocalSseAdapter extends DirectSseAdapter { +} + +export default LocalSseAdapter; diff --git a/resources/js/adapters/mercure-sse-adapter.d.ts b/resources/js/adapters/mercure-sse-adapter.d.ts new file mode 100644 index 0000000..b633a3b --- /dev/null +++ b/resources/js/adapters/mercure-sse-adapter.d.ts @@ -0,0 +1,14 @@ +import type { + MercureSseAdapterOptions, + SseAdapter, + SseAdapterConnection, + SseAdapterContext, +} from '../sse-client.js'; + +export declare class MercureSseAdapter implements SseAdapter { + constructor(options?: MercureSseAdapterOptions); + resolve(context: SseAdapterContext): Promise; + cancel(): void; +} + +export default MercureSseAdapter; diff --git a/resources/js/adapters/mercure-sse-adapter.js b/resources/js/adapters/mercure-sse-adapter.js new file mode 100644 index 0000000..9362a6a --- /dev/null +++ b/resources/js/adapters/mercure-sse-adapter.js @@ -0,0 +1,227 @@ +const DEFAULT_TIMEOUT_MILLISECONDS = 15_000; + +export class MercureSseAdapter { + constructor({ + fetchFactory = null, + timeout = DEFAULT_TIMEOUT_MILLISECONDS, + } = {}) { + if (fetchFactory !== null && typeof fetchFactory !== 'function') { + throw new TypeError( + 'MercureSseAdapter fetchFactory must be a function or null.', + ); + } + + if ( + !Number.isFinite(timeout) + || timeout <= 0 + || timeout > 2_147_483_647 + ) { + throw new TypeError( + 'MercureSseAdapter timeout must be a positive finite number.', + ); + } + + this._fetchFactory = fetchFactory; + this._timeout = timeout; + this._controller = null; + this._timeoutId = null; + } + + /** + * @param {{url: string, withCredentials: boolean}} context + * @returns {Promise<{url: string, expiresAt: number|null}>} + */ + async resolve({ url, withCredentials }) { + const fetchFactory = this._resolveFetchFactory(); + + if (fetchFactory === null) { + throw new Error('Fetch is required by the Mercure SSE adapter.'); + } + + this.cancel(); + + const controller = this._createAbortController(); + this._controller = controller; + + try { + const response = await Promise.race([ + fetchFactory(url, { + method: 'GET', + headers: { + Accept: 'application/json', + }, + credentials: withCredentials ? 'include' : 'same-origin', + cache: 'no-store', + ...(controller === null ? {} : { signal: controller.signal }), + }), + this._timeoutPromise(controller), + ]); + + const bootstrap = await this._readJsonResponse(response); + const authorization = this._normalizeBootstrap(bootstrap); + + return { + url: this._buildHubUrl(authorization.hub, authorization.topics), + expiresAt: authorization.expiresAt, + }; + } finally { + this._clearTimeout(); + + if (this._controller === controller) { + this._controller = null; + } + } + } + + cancel() { + this._controller?.abort(); + this._controller = null; + this._clearTimeout(); + } + + _resolveFetchFactory() { + if (this._fetchFactory !== null) { + return this._fetchFactory; + } + + if ( + typeof globalThis === 'undefined' + || typeof globalThis.fetch !== 'function' + ) { + return null; + } + + return globalThis.fetch.bind(globalThis); + } + + _createAbortController() { + if ( + typeof globalThis === 'undefined' + || typeof globalThis.AbortController !== 'function' + ) { + return null; + } + + return new globalThis.AbortController(); + } + + _timeoutPromise(controller) { + return new Promise((resolve, reject) => { + this._timeoutId = setTimeout(() => { + controller?.abort(); + reject(new Error('Mercure authorization timed out.')); + }, this._timeout); + + this._timeoutId?.unref?.(); + }); + } + + async _readJsonResponse(response) { + if ( + response === null + || typeof response !== 'object' + || typeof response.json !== 'function' + ) { + throw new TypeError( + 'The Mercure authorization endpoint returned an invalid response.', + ); + } + + if (response.ok !== true) { + throw new Error( + `Mercure authorization failed with HTTP ${response.status ?? 0}.`, + ); + } + + try { + return await response.json(); + } catch (error) { + throw new TypeError( + 'The Mercure authorization endpoint returned invalid JSON.', + { cause: error }, + ); + } + } + + /** + * @param {*} bootstrap + * @returns {{hub: string, topics: string[], expiresAt: number|null}} + */ + _normalizeBootstrap(bootstrap) { + const expiresAt = bootstrap?.expiresAt ?? null; + + if ( + bootstrap === null + || typeof bootstrap !== 'object' + || typeof bootstrap.hub !== 'string' + || bootstrap.hub.trim() === '' + || !Array.isArray(bootstrap.topics) + || bootstrap.topics.length === 0 + || bootstrap.topics.some((topic) => ( + typeof topic !== 'string' || topic.trim() === '' + )) + || ( + expiresAt !== null + && ( + !Number.isSafeInteger(expiresAt) + || expiresAt <= Math.floor(Date.now() / 1000) + ) + ) + ) { + throw new TypeError( + 'The Mercure authorization endpoint returned invalid bootstrap data.', + ); + } + + return { + hub: bootstrap.hub.trim(), + topics: [...new Set( + bootstrap.topics.map((topic) => topic.trim()), + )], + expiresAt, + }; + } + + /** + * @param {string} hub + * @param {string[]} topics + * @returns {string} + */ + _buildHubUrl(hub, topics) { + let url; + + try { + url = new URL(hub); + } catch (error) { + throw new TypeError( + 'The Mercure Hub URL must be absolute.', + { cause: error }, + ); + } + + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new TypeError('The Mercure Hub URL must use HTTP or HTTPS.'); + } + + url.searchParams.delete('topic'); + + for (const topic of topics) { + url.searchParams.append('topic', topic); + } + + url.hash = ''; + + return url.toString(); + } + + _clearTimeout() { + if (this._timeoutId === null) { + return; + } + + clearTimeout(this._timeoutId); + this._timeoutId = null; + } +} + +export default MercureSseAdapter; diff --git a/resources/js/adapters/redis-sse-adapter.d.ts b/resources/js/adapters/redis-sse-adapter.d.ts new file mode 100644 index 0000000..df1d660 --- /dev/null +++ b/resources/js/adapters/redis-sse-adapter.d.ts @@ -0,0 +1,6 @@ +import { DirectSseAdapter } from './direct-sse-adapter.js'; + +export declare class RedisSseAdapter extends DirectSseAdapter { +} + +export default RedisSseAdapter; diff --git a/resources/js/adapters/redis-sse-adapter.js b/resources/js/adapters/redis-sse-adapter.js new file mode 100644 index 0000000..467128a --- /dev/null +++ b/resources/js/adapters/redis-sse-adapter.js @@ -0,0 +1,6 @@ +import { DirectSseAdapter } from './direct-sse-adapter.js'; + +export class RedisSseAdapter extends DirectSseAdapter { +} + +export default RedisSseAdapter; diff --git a/resources/js/sse-client.d.ts b/resources/js/sse-client.d.ts index 370a3aa..1a1f1a2 100644 --- a/resources/js/sse-client.d.ts +++ b/resources/js/sse-client.d.ts @@ -29,6 +29,72 @@ export type SseQuery = export type SseChannelInput = string | readonly string[]; +export interface SseAdapterContext { + readonly url: string; + readonly channels: readonly string[]; + readonly withCredentials: boolean; + readonly client: SseClient; +} + +export interface SseAdapterConnection { + readonly url: string; + readonly expiresAt?: number | null; +} + +export interface SseAdapter { + resolve( + context: SseAdapterContext, + ): SseAdapterConnection | PromiseLike; + cancel?(): void; +} + +export interface MercureSseAdapterOptions { + /** + * Optional Fetch-compatible factory, mainly useful for tests. + */ + readonly fetchFactory?: SseFetchFactory | null; + + /** + * Authorization request timeout in milliseconds. + */ + readonly timeout?: number; +} + +/** + * Direct EventSource adapter for PHP-streamed brokers. + */ +export declare class DirectSseAdapter implements SseAdapter { + resolve(context: SseAdapterContext): SseAdapterConnection; + cancel(): void; +} + +/** + * Semantic alias for the package local broker. + */ +export declare class LocalSseAdapter extends DirectSseAdapter { +} + +/** + * Semantic alias for Redis-backed PHP streaming. + */ +export declare class RedisSseAdapter extends DirectSseAdapter { +} + +/** + * Semantic alias for the in-memory PHP stream broker. + */ +export declare class InMemorySseAdapter extends DirectSseAdapter { +} + +/** + * Resolves the package Mercure authorization endpoint to a Hub EventSource URL. + */ +export declare class MercureSseAdapter implements SseAdapter { + constructor(options?: MercureSseAdapterOptions); + resolve(context: SseAdapterContext): Promise; + cancel(): void; +} + /** * Parsed message delivered to named event handlers and global message handlers. */ @@ -104,7 +170,7 @@ export type SseFallbackReason = | 'unsupported' | 'construction-error' | 'connection-error' - | 'bootstrap-error'; + | 'adapter-error'; export interface SseFallbackContext { readonly reason: SseFallbackReason; @@ -145,6 +211,11 @@ export interface SseClientOptions { */ readonly endpoint: string; + /** + * Broker adapter. Defaults to DirectSseAdapter. + */ + readonly adapter?: SseAdapter | null; + /** * Logical channel names sent as the channels query parameter. */ @@ -156,12 +227,12 @@ export interface SseClientOptions { readonly query?: SseQuery; /** - * Enables cross-origin credentials for bootstrap fetch and EventSource. + * Passed to native EventSource for credentialed CORS/cookie requests. */ readonly withCredentials?: boolean; /** - * Optional application fallback for bootstrap, connection, or browser errors. + * Optional application fallback for adapter, connection, or browser errors. */ readonly fallback?: SseFallback | null; @@ -169,17 +240,13 @@ export interface SseClientOptions { * Optional EventSource factory, mainly useful for tests. */ readonly eventSourceFactory?: SseEventSourceFactory | null; - - /** - * Optional Fetch-compatible factory used to resolve the stream endpoint. - */ - readonly fetchFactory?: SseFetchFactory | null; } export declare class SseClient { constructor(options?: SseClientOptions); readonly endpoint: string; + readonly adapter: SseAdapter; readonly channels: string[]; readonly query: SseQuery; readonly withCredentials: boolean; @@ -261,8 +328,8 @@ export declare class SseClient { unsubscribe(channels: SseChannelInput): this; /** - * Resolve and open the EventSource connection asynchronously. Repeated - * calls are idempotent while active. + * Open the EventSource connection. Repeated calls are idempotent while + * active. */ connect(): this; diff --git a/resources/js/sse-client.js b/resources/js/sse-client.js index e46ec0b..cb144a6 100644 --- a/resources/js/sse-client.js +++ b/resources/js/sse-client.js @@ -1,11 +1,18 @@ /** * Small EventSource wrapper for maniaba/codeigniter4-sse. * - * The browser owns reconnect timing. This client does not create a competing - * retry loop; it reports the reconnecting status while the native EventSource - * follows the server's `retry` hint. + * Broker-specific connection resolution lives in adapters. The client owns + * lifecycle, message normalization, channel changes, and EventSource handlers. */ +import { DirectSseAdapter } from './adapters/direct-sse-adapter.js'; + +export { DirectSseAdapter } from './adapters/direct-sse-adapter.js'; +export { InMemorySseAdapter } from './adapters/in-memory-sse-adapter.js'; +export { LocalSseAdapter } from './adapters/local-sse-adapter.js'; +export { MercureSseAdapter } from './adapters/mercure-sse-adapter.js'; +export { RedisSseAdapter } from './adapters/redis-sse-adapter.js'; + export const SseClientStatus = Object.freeze({ IDLE: 'idle', CONNECTING: 'connecting', @@ -19,14 +26,6 @@ const GLOBAL_MESSAGE_EVENT = 'message'; const STATUS_EVENT = 'status'; const RESERVED_NATIVE_EVENTS = new Set(['open', 'error']); -class BootstrapRequestError extends Error { - constructor(message, retryable, cause = null) { - super(message, cause === null ? undefined : { cause }); - this.name = 'BootstrapRequestError'; - this.retryable = retryable; - } -} - /** * @typedef {Object} SseMessage * @property {string|null} id @@ -44,12 +43,12 @@ class BootstrapRequestError extends Error { /** * @typedef {Object} SseClientOptions * @property {string} endpoint + * @property {Object|null} [adapter] * @property {string[]} [channels] * @property {Object|URLSearchParams} [query] * @property {boolean} [withCredentials] * @property {Function|null} [fallback] * @property {Function|null} [eventSourceFactory] - * @property {Function|null} [fetchFactory] */ export class SseClient { @@ -58,12 +57,12 @@ export class SseClient { */ constructor({ endpoint, + adapter = new DirectSseAdapter(), channels = [], query = {}, withCredentials = true, fallback = null, eventSourceFactory = null, - fetchFactory = null, } = {}) { const queryIsUrlSearchParams = ( typeof globalThis !== 'undefined' @@ -84,6 +83,14 @@ export class SseClient { ); } + if ( + adapter === null + || typeof adapter !== 'object' + || typeof adapter.resolve !== 'function' + ) { + throw new TypeError('SseClient adapter must provide a resolve() method.'); + } + if (fallback !== null && typeof fallback !== 'function') { throw new TypeError('SseClient fallback must be a function or null.'); } @@ -97,13 +104,8 @@ export class SseClient { ); } - if (fetchFactory !== null && typeof fetchFactory !== 'function') { - throw new TypeError( - 'SseClient fetchFactory must be a function or null.', - ); - } - this.endpoint = endpoint.trim(); + this.adapter = adapter; this.channels = this._normalizeChannels(channels); this.query = query; this.withCredentials = Boolean(withCredentials); @@ -111,7 +113,6 @@ export class SseClient { this._queryIsUrlSearchParams = queryIsUrlSearchParams; this._fallback = fallback; this._eventSourceFactory = eventSourceFactory; - this._fetchFactory = fetchFactory; this._listeners = new Map(); this._nativeMessageHandlers = new Map(); this._source = null; @@ -121,9 +122,6 @@ export class SseClient { this._fallbackInvoked = false; this._connectionGeneration = 0; this._refreshTimer = null; - this._bootstrapController = null; - this._bootstrapRetryTimer = null; - this._bootstrapRetryAttempt = 0; this._handleOpen = this._handleOpen.bind(this); this._handleError = this._handleError.bind(this); @@ -299,7 +297,8 @@ export class SseClient { /** * Open the EventSource connection. * - * Repeated calls while a source is active are idempotent. + * Repeated calls while a source or adapter resolution is active are + * idempotent. * * @returns {SseClient} */ @@ -313,21 +312,32 @@ export class SseClient { return this; } - _startConnection(isRetry = false) { - this._abortBootstrap(); - this._clearBootstrapRetryTimer(); + /** + * Close the active source. Registered handlers remain available for a + * later connect() call. + * + * @returns {SseClient} + */ + close() { + this._manuallyClosed = true; + this._connectionGeneration++; + this._cancelAdapter(); + this._teardownSource(); + this._setStatus(SseClientStatus.CLOSED, { reason: 'manual' }); - if (this._source !== null) { - this._teardownSource(); - } + return this; + } - this._manuallyClosed = false; + _startConnection(preserveSource = false) { + this._cancelAdapter(); + this._clearRefreshTimer(); - if (!isRetry) { - this._fallbackInvoked = false; - this._bootstrapRetryAttempt = 0; + if (this._source !== null && !preserveSource) { + this._teardownSource(); } + this._manuallyClosed = false; + this._fallbackInvoked = false; const generation = ++this._connectionGeneration; const factory = this._resolveEventSourceFactory(); @@ -354,9 +364,73 @@ export class SseClient { return; } - this._currentUrl = this._buildUrl(); - this._setStatus(SseClientStatus.CONNECTING); + const endpointUrl = this._buildUrl(); + this._currentUrl = endpointUrl; + this._setStatus(preserveSource + ? SseClientStatus.RECONNECTING + : SseClientStatus.CONNECTING); + + let resolution; + + try { + resolution = this.adapter.resolve({ + url: endpointUrl, + channels: [...this.channels], + withCredentials: this.withCredentials, + client: this, + }); + } catch (error) { + this._handleAdapterError(error, generation, preserveSource, true); + + return; + } + + if (resolution !== null && typeof resolution?.then === 'function') { + resolution + .then((connection) => { + this._openAdapterConnection( + connection, + factory, + generation, + preserveSource, + false, + ); + }) + .catch((error) => { + this._handleAdapterError( + error, + generation, + preserveSource, + false, + ); + }); + + return; + } + + this._openAdapterConnection( + resolution, + factory, + generation, + preserveSource, + true, + ); + } + /** + * @param {*} connection + * @param {Function} factory + * @param {number} generation + * @param {boolean} preserveSource + * @param {boolean} throwOnUnhandled + */ + _openAdapterConnection( + connection, + factory, + generation, + preserveSource, + throwOnUnhandled, + ) { if ( generation !== this._connectionGeneration || this._manuallyClosed @@ -364,7 +438,82 @@ export class SseClient { return; } - this._connectThroughBootstrap(factory, generation); + let normalized; + + try { + normalized = this._normalizeConnection(connection); + } catch (error) { + this._handleAdapterError( + error, + generation, + preserveSource, + throwOnUnhandled, + ); + + return; + } + + if (preserveSource && this._source !== null) { + this._teardownSource(); + } + + this._currentUrl = normalized.url; + const opened = this._openSource( + factory, + normalized.url, + throwOnUnhandled, + ); + + if (opened) { + this._scheduleRefresh(normalized.expiresAt, generation); + } + } + + /** + * @param {*} connection + * @returns {{url: string, expiresAt: number|null}} + */ + _normalizeConnection(connection) { + const url = connection?.url; + const expiresAt = connection?.expiresAt ?? null; + + if ( + connection === null + || typeof connection !== 'object' + || typeof url !== 'string' + || url.trim() === '' + || ( + expiresAt !== null + && ( + !Number.isSafeInteger(expiresAt) + || expiresAt <= Math.floor(Date.now() / 1000) + ) + ) + ) { + throw new TypeError('The SSE adapter returned invalid connection data.'); + } + + let streamUrl; + + try { + streamUrl = new URL(url); + } catch (error) { + throw new TypeError( + 'The SSE adapter returned an invalid stream URL.', + { cause: error }, + ); + } + + if (streamUrl.protocol !== 'http:' && streamUrl.protocol !== 'https:') { + throw new TypeError('The SSE stream URL must use HTTP or HTTPS.'); + } + + streamUrl.hash = ''; + + return { + url: streamUrl.toString(), + expiresAt, + }; } /** @@ -458,21 +607,6 @@ export class SseClient { return true; } - /** - * Close the active source. Registered handlers remain available for a - * later connect() call. - * - * @returns {SseClient} - */ - close() { - this._manuallyClosed = true; - this._connectionGeneration++; - this._teardownSource(); - this._setStatus(SseClientStatus.CLOSED, { reason: 'manual' }); - - return this; - } - /** * @returns {Function|null} */ @@ -492,250 +626,46 @@ export class SseClient { } /** - * @returns {Function|null} - */ - _resolveFetchFactory() { - if (this._fetchFactory !== null) { - return this._fetchFactory; - } - - if ( - typeof globalThis === 'undefined' - || typeof globalThis.fetch !== 'function' - ) { - return null; - } - - return globalThis.fetch.bind(globalThis); - } - - _createAbortController() { - if ( - typeof globalThis === 'undefined' - || typeof globalThis.AbortController !== 'function' - ) { - return null; - } - - return new globalThis.AbortController(); - } - - /** - * @param {Function} eventSourceFactory + * @param {*} error * @param {number} generation + * @param {boolean} preserveSource + * @param {boolean} throwOnUnhandled */ - _connectThroughBootstrap(eventSourceFactory, generation) { - const fetchFactory = this._resolveFetchFactory(); - - if (fetchFactory === null) { - this._handleBootstrapError( - new Error('Fetch is required to resolve the SSE stream.'), - generation, - false, - ); - - return; - } - - const controller = this._createAbortController(); - this._bootstrapController = controller; - - Promise.resolve(this._requestBootstrap( - fetchFactory, - this._currentUrl, - controller?.signal, - )) - .then((bootstrap) => { - if ( - generation !== this._connectionGeneration - || this._manuallyClosed - ) { - return; - } - - const connection = this._normalizeBootstrap(bootstrap); - const streamUrl = this._buildStreamUrl( - connection.url, - connection.query, - ); - - this._bootstrapRetryAttempt = 0; - this._currentUrl = streamUrl; - const opened = this._openSource( - eventSourceFactory, - streamUrl, - false, - ); - - if (opened) { - this._scheduleRefresh( - connection.expiresAt, - generation, - ); - } - }) - .catch((error) => { - this._handleBootstrapError( - error, - generation, - error instanceof BootstrapRequestError && error.retryable, - ); - }) - .finally(() => { - if (this._bootstrapController === controller) { - this._bootstrapController = null; - } - }); - } - - async _requestBootstrap(fetchFactory, url, signal) { - let response; - - try { - response = await fetchFactory(url, { - method: 'GET', - headers: { - Accept: 'application/json', - }, - credentials: this.withCredentials ? 'include' : 'same-origin', - cache: 'no-store', - ...(signal === undefined ? {} : { signal }), - }); - } catch (error) { - throw new BootstrapRequestError( - 'The SSE bootstrap request failed.', - true, - error, - ); - } - + _handleAdapterError(error, generation, preserveSource, throwOnUnhandled) { if ( - response === null - || typeof response !== 'object' - || typeof response.json !== 'function' + generation !== this._connectionGeneration + || this._manuallyClosed ) { - throw new BootstrapRequestError( - 'The SSE bootstrap endpoint returned an invalid response.', - false, - ); - } - - if (response.ok !== true) { - const status = Number.isInteger(response.status) - ? response.status - : 0; - - throw new BootstrapRequestError( - `SSE bootstrap failed with HTTP ${status}.`, - status === 0 - || status === 408 - || status === 425 - || status === 429 - || status >= 500, - ); + return; } - try { - return await response.json(); - } catch (error) { - throw new BootstrapRequestError( - 'The SSE bootstrap endpoint returned invalid JSON.', - false, - error, - ); + if (preserveSource && this._source !== null) { + this._teardownSource(); + } else { + this._source = null; } - } - /** - * @param {*} bootstrap - * @returns {{url: string, query: Object, expiresAt: number|null}} - */ - _normalizeBootstrap(bootstrap) { - const hasUrl = ( - bootstrap !== null - && typeof bootstrap === 'object' - && Object.prototype.hasOwnProperty.call(bootstrap, 'url') - ); - const query = bootstrap?.query ?? {}; - const expiresAt = bootstrap?.expiresAt ?? null; + this._setStatus(SseClientStatus.CLOSED, { + reason: 'adapter-error', + error, + }); if ( - !hasUrl - || ( - bootstrap.url !== null - && ( - typeof bootstrap.url !== 'string' - || bootstrap.url.trim() === '' - ) - ) - || query === null - || typeof query !== 'object' - || Array.isArray(query) - || Object.values(query).some((value) => !this._isQueryValue(value)) - || ( - expiresAt !== null - && ( - !Number.isSafeInteger(expiresAt) - || expiresAt <= Math.floor(Date.now() / 1000) - ) - ) + generation !== this._connectionGeneration + || this._manuallyClosed ) { - throw new TypeError( - 'The SSE bootstrap endpoint returned invalid connection data.', - ); + return; } - return { - url: bootstrap.url === null - ? this._currentUrl - : bootstrap.url.trim(), - query, - expiresAt, - }; - } - - /** - * @param {string} endpoint - * @param {Object} query - * @returns {string} - */ - _buildStreamUrl(endpoint, query) { - let url; + const handled = this._invokeFallback('adapter-error', null, error); - try { - url = new URL(endpoint, this._currentUrl); - } catch (error) { - throw new TypeError( - 'The SSE bootstrap endpoint returned an invalid stream URL.', - { cause: error }, - ); - } - - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - throw new TypeError( - 'The SSE stream URL must use HTTP or HTTPS.', - ); + if (!handled && throwOnUnhandled) { + throw error; } - for (const [name, value] of Object.entries(query)) { - if (value === null || value === undefined) { - continue; - } - - url.searchParams.delete(name); - - if (Array.isArray(value)) { - for (const item of value) { - url.searchParams.append(name, String(item)); - } - } else { - url.searchParams.set(name, String(value)); - } + if (!handled) { + this._reportHandlerError(error); } - - url.hash = ''; - - return url.toString(); } /** @@ -762,89 +692,12 @@ export class SseClient { return; } - this._startConnection(); + this._startConnection(true); }, delay); this._refreshTimer?.unref?.(); } - /** - * @param {*} error - * @param {number} generation - */ - _handleBootstrapError(error, generation, retryable) { - if ( - generation !== this._connectionGeneration - || this._manuallyClosed - ) { - return; - } - - this._source = null; - - if (retryable) { - this._bootstrapRetryAttempt++; - this._setStatus(SseClientStatus.RECONNECTING, { - reason: 'bootstrap-error', - error, - }); - - const handled = this._invokeFallback( - 'bootstrap-error', - null, - error, - ); - - if ( - generation !== this._connectionGeneration - || this._manuallyClosed - ) { - return; - } - - const delay = Math.min( - 30000, - 1000 * (2 ** Math.min(this._bootstrapRetryAttempt - 1, 5)), - ); - - this._bootstrapRetryTimer = setTimeout(() => { - this._bootstrapRetryTimer = null; - - if ( - generation !== this._connectionGeneration - || this._manuallyClosed - ) { - return; - } - - this._startConnection(true); - }, delay); - - this._bootstrapRetryTimer?.unref?.(); - - if (!handled && this._bootstrapRetryAttempt === 1) { - this._reportHandlerError(error); - } - - return; - } - - this._setStatus(SseClientStatus.CLOSED, { - reason: 'bootstrap-error', - error, - }); - - const handled = this._invokeFallback( - 'bootstrap-error', - null, - error, - ); - - if (!handled) { - this._reportHandlerError(error); - } - } - /** * @returns {boolean} */ @@ -853,7 +706,6 @@ export class SseClient { ( this._source !== null || this._status === SseClientStatus.CONNECTING - || this._bootstrapRetryTimer !== null ) && this._status !== SseClientStatus.CLOSED && this._status !== SseClientStatus.UNSUPPORTED @@ -865,9 +717,11 @@ export class SseClient { return; } + this._connectionGeneration++; + this._cancelAdapter(); + this._teardownSource(); + if (this.channels.length === 0) { - this._connectionGeneration++; - this._teardownSource(); this._setStatus(SseClientStatus.CLOSED, { reason: 'channels-empty', }); @@ -934,26 +788,6 @@ export class SseClient { return url.toString(); } - /** - * @param {*} value - * @returns {boolean} - */ - _isQueryValue(value) { - if (Array.isArray(value)) { - return value.every((item) => ( - !Array.isArray(item) && this._isQueryValue(item) - )); - } - - return ( - value === null - || value === undefined - || typeof value === 'string' - || typeof value === 'boolean' - || (typeof value === 'number' && Number.isFinite(value)) - ); - } - /** * @param {string|string[]} channels * @returns {string[]} @@ -1197,8 +1031,6 @@ export class SseClient { } _teardownSource() { - this._abortBootstrap(); - this._clearBootstrapRetryTimer(); this._clearRefreshTimer(); if (this._source === null) { @@ -1232,18 +1064,10 @@ export class SseClient { this._refreshTimer = null; } - _abortBootstrap() { - this._bootstrapController?.abort(); - this._bootstrapController = null; - } - - _clearBootstrapRetryTimer() { - if (this._bootstrapRetryTimer === null) { - return; + _cancelAdapter() { + if (typeof this.adapter.cancel === 'function') { + this.adapter.cancel(); } - - clearTimeout(this._bootstrapRetryTimer); - this._bootstrapRetryTimer = null; } /** diff --git a/src/Broker/Mercure/MercureSubscriptionEndpoint.php b/src/Broker/Mercure/MercureSubscriptionEndpoint.php index 4e15710..12dd40d 100644 --- a/src/Broker/Mercure/MercureSubscriptionEndpoint.php +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -9,11 +9,12 @@ use Maniaba\CodeIgniterSse\Config\Sse; use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorInterface; use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; -use Maniaba\CodeIgniterSse\Contracts\SubscriptionEndpointInterface; +use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; +use Maniaba\CodeIgniterSse\HTTP\AcceptHeaderNegotiator; use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; -final readonly class MercureSubscriptionEndpoint implements SubscriptionEndpointInterface, ChannelSelectorValidatorProviderInterface +final readonly class MercureSubscriptionEndpoint implements PreflightSubscriptionEndpointInterface, ChannelSelectorValidatorProviderInterface { public function __construct( private Sse $config, @@ -28,6 +29,33 @@ public function channelSelectorValidator(): ChannelSelectorValidatorInterface return new ChannelNameValidator(); } + public function preflight(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + $accept = $request->getHeaderLine('Accept'); + + if ( + $accept === '' + || (new AcceptHeaderNegotiator())->preferred( + $accept, + ['bootstrap' => 'application/json'], + ) === 'bootstrap' + ) { + return null; + } + + return $response + ->setStatusCode(406) + ->setJSON([ + 'error' => [ + 'code' => 'not_acceptable', + 'message' => 'This endpoint requires Accept: application/json.', + ], + ]) + ->setHeader('Cache-Control', 'private, no-store') + ->appendHeader('Vary', 'Accept') + ->setHeader('X-Content-Type-Options', 'nosniff'); + } + public function respond( RequestInterface $request, ResponseInterface $response, @@ -40,8 +68,8 @@ public function respond( $response = $response ->setStatusCode(200) ->setJSON([ - 'url' => $subscription->hubUrl, - 'query' => ['topic' => $subscription->topics], + 'hub' => $subscription->hubUrl, + 'topics' => $subscription->topics, 'expiresAt' => $subscription->expiresAt, ]) ->setHeader('Cache-Control', 'private, no-store') diff --git a/src/Commands/InstallCommand.php b/src/Commands/InstallCommand.php index bd1fb73..265e869 100644 --- a/src/Commands/InstallCommand.php +++ b/src/Commands/InstallCommand.php @@ -54,6 +54,23 @@ public function run(array $params): int )) { $failed = true; } + + $adapterFiles = glob($root . '/resources/js/adapters/*.{js,d.ts}', GLOB_BRACE); + + if ($adapterFiles === false) { + CLI::error('Unable to read browser adapter resources.'); + $failed = true; + } else { + foreach ($adapterFiles as $adapterFile) { + if (! $this->publish( + $adapterFile, + FCPATH . 'vendor/codeigniter4-sse/adapters/' . basename($adapterFile), + $force, + )) { + $failed = true; + } + } + } } return $failed ? EXIT_ERROR : EXIT_SUCCESS; diff --git a/src/Contracts/SubscriptionEndpointInterface.php b/src/Contracts/SubscriptionEndpointInterface.php index 6430bd3..def6301 100644 --- a/src/Contracts/SubscriptionEndpointInterface.php +++ b/src/Contracts/SubscriptionEndpointInterface.php @@ -10,10 +10,6 @@ interface SubscriptionEndpointInterface { /** - * Browser bootstrap responses contain url (null reuses the request URL), - * optional query parameters, and optional expiresAt. PHP stream endpoints - * may instead return a streaming response for text/event-stream. - * * @param list $channels */ public function respond( diff --git a/src/Endpoint/LocalSseSubscriptionEndpoint.php b/src/Endpoint/LocalSseSubscriptionEndpoint.php index d78f0b8..3c4d639 100644 --- a/src/Endpoint/LocalSseSubscriptionEndpoint.php +++ b/src/Endpoint/LocalSseSubscriptionEndpoint.php @@ -10,6 +10,7 @@ use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; use Maniaba\CodeIgniterSse\Contracts\PreflightSubscriptionEndpointInterface; use Maniaba\CodeIgniterSse\Contracts\SseOutputInterface; +use Maniaba\CodeIgniterSse\HTTP\AcceptHeaderNegotiator; use Maniaba\CodeIgniterSse\HTTP\SseResponseFactory; use Maniaba\CodeIgniterSse\Stream\SseConnectionManager; use Maniaba\CodeIgniterSse\Support\ChannelNameValidator; @@ -31,7 +32,13 @@ public function channelSelectorValidator(): ChannelSelectorValidatorInterface public function preflight(RequestInterface $request, ResponseInterface $response): ?ResponseInterface { - if ($this->requestedRepresentation($request) !== null || ! $this->requireAcceptHeader) { + if ( + ! $this->requireAcceptHeader + || (new AcceptHeaderNegotiator())->preferred( + strtolower($request->getHeaderLine('Accept')), + ['stream' => 'text/event-stream'], + ) === 'stream' + ) { return null; } @@ -39,7 +46,7 @@ public function preflight(RequestInterface $request, ResponseInterface $response $response, 406, 'not_acceptable', - 'This endpoint requires Accept: text/event-stream or application/json.', + 'This endpoint requires Accept: text/event-stream.', ); } @@ -48,18 +55,6 @@ public function respond( ResponseInterface $response, array $channels, ): ResponseInterface { - if ($this->requestedRepresentation($request) === 'bootstrap') { - return $response - ->setStatusCode(200) - ->setJSON([ - 'url' => null, - 'expiresAt' => null, - ]) - ->setHeader('Cache-Control', 'private, no-store') - ->appendHeader('Vary', 'Accept') - ->setHeader('X-Content-Type-Options', 'nosniff'); - } - $factory = $this->responseFactory ?? new SseResponseFactory($response); $response = $factory->create( @@ -73,114 +68,6 @@ function (SseOutputInterface $output) use ($channels): void { return $response; } - private function requestedRepresentation(RequestInterface $request): ?string - { - $accept = strtolower($request->getHeaderLine('Accept')); - - if ($accept === '') { - return null; - } - - $bootstrapFound = false; - $bootstrapQuality = 0.0; - $bootstrapOrder = PHP_INT_MAX; - $streamQuality = 0.0; - $streamSpecificity = -1; - $streamOrder = PHP_INT_MAX; - - foreach (explode(',', $accept) as $order => $mediaRange) { - $mediaRange = trim($mediaRange); - - if ($mediaRange === '') { - continue; - } - - [$mediaType, $rangeQuality] = $this->parseAcceptRange($mediaRange); - - if ( - $mediaType === 'application/json' - && (! $bootstrapFound || $rangeQuality > $bootstrapQuality) - ) { - $bootstrapFound = true; - $bootstrapQuality = $rangeQuality; - $bootstrapOrder = $order; - } - - $rangeSpecificity = $this->eventStreamSpecificity($mediaType); - - if ( - $rangeSpecificity > $streamSpecificity - || ($rangeSpecificity === $streamSpecificity && $rangeQuality > $streamQuality) - ) { - $streamQuality = $rangeQuality; - $streamSpecificity = $rangeSpecificity; - $streamOrder = $order; - } - } - - $bootstrapAccepted = $bootstrapFound && $bootstrapQuality > 0.0; - $streamAccepted = $streamSpecificity >= 0 && $streamQuality > 0.0; - - if (! $bootstrapAccepted) { - return $streamAccepted ? 'stream' : null; - } - - if (! $streamAccepted) { - return 'bootstrap'; - } - - if ($bootstrapQuality !== $streamQuality) { - return $bootstrapQuality > $streamQuality ? 'bootstrap' : 'stream'; - } - - if ($streamSpecificity !== 2) { - return $streamSpecificity < 2 ? 'bootstrap' : 'stream'; - } - - return $bootstrapOrder < $streamOrder ? 'bootstrap' : 'stream'; - } - - /** - * @return array{0: string, 1: float} - */ - private function parseAcceptRange(string $mediaRange): array - { - $parts = array_map(trim(...), explode(';', $mediaRange)); - $mediaType = array_shift($parts) ?? ''; - $quality = 1.0; - - foreach ($parts as $parameter) { - if (! str_starts_with($parameter, 'q=')) { - continue; - } - - $quality = $this->parseQuality(substr($parameter, 2)); - - break; - } - - return [$mediaType, $quality]; - } - - private function parseQuality(string $value): float - { - if (preg_match('/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/D', $value) !== 1) { - return 0.0; - } - - return (float) $value; - } - - private function eventStreamSpecificity(string $mediaType): int - { - return match ($mediaType) { - 'text/event-stream' => 2, - 'text/*' => 1, - '*/*' => 0, - default => -1, - }; - } - private function error( ResponseInterface $response, int $status, @@ -194,6 +81,9 @@ private function error( 'code' => $code, 'message' => $message, ], - ]); + ]) + ->setHeader('Cache-Control', 'private, no-store') + ->appendHeader('Vary', 'Accept') + ->setHeader('X-Content-Type-Options', 'nosniff'); } } diff --git a/src/HTTP/AcceptHeaderNegotiator.php b/src/HTTP/AcceptHeaderNegotiator.php new file mode 100644 index 0000000..c0a6739 --- /dev/null +++ b/src/HTTP/AcceptHeaderNegotiator.php @@ -0,0 +1,183 @@ + $supported Name-to-media-type map in server preference order. + * + * @return string|null The selected name, or null when no representation is acceptable. + */ + public function preferred(string $header, array $supported): ?string + { + if ($header === '') { + return null; + } + + $ranges = $this->parse($header); + $best = null; + + foreach ($supported as $name => $mediaType) { + $preference = $this->preferenceFor($mediaType, $ranges); + + if ($preference === null || $preference['quality'] <= 0.0) { + continue; + } + + if ($best === null || $this->isPreferred($preference, $best)) { + $best = ['name' => $name, ...$preference]; + } + } + + return $best['name'] ?? null; + } + + /** + * @return list + */ + private function parse(string $header): array + { + $ranges = []; + + foreach (explode(',', strtolower($header)) as $order => $value) { + $parts = array_map(trim(...), explode(';', $value)); + $mediaType = array_shift($parts) ?? ''; + $typeParts = explode('/', $mediaType, 2); + + if (count($typeParts) !== 2) { + continue; + } + + $quality = 1.0; + + foreach ($parts as $parameter) { + $pair = array_map(trim(...), explode('=', $parameter, 2)); + + if (($pair[0] ?? '') !== 'q') { + continue; + } + + $quality = $this->parseQuality($pair[1] ?? ''); + + break; + } + + $ranges[] = [ + 'type' => $typeParts[0], + 'subtype' => $typeParts[1], + 'quality' => $quality, + 'order' => $order, + ]; + } + + return $ranges; + } + + /** + * @param list $ranges + * + * @return array{quality: float, specificity: int, order: int}|null + */ + private function preferenceFor(string $mediaType, array $ranges): ?array + { + $parts = explode('/', strtolower($mediaType), 2); + + if (count($parts) !== 2) { + return null; + } + + $best = null; + + foreach ($ranges as $range) { + $specificity = $this->specificity( + $range['type'], + $range['subtype'], + $parts[0], + $parts[1], + ); + + if ($specificity < 0) { + continue; + } + + $candidate = [ + 'quality' => $range['quality'], + 'specificity' => $specificity, + 'order' => $range['order'], + ]; + + if ($best === null || $this->isMoreSpecific($candidate, $best)) { + $best = $candidate; + } + } + + return $best; + } + + private function specificity( + string $rangeType, + string $rangeSubtype, + string $supportedType, + string $supportedSubtype, + ): int { + if ($rangeType === '*' && $rangeSubtype === '*') { + return 0; + } + + if ($rangeType !== $supportedType) { + return -1; + } + + if ($rangeSubtype === '*') { + return 1; + } + + return $rangeSubtype === $supportedSubtype ? 2 : -1; + } + + /** + * @param array{quality: float, specificity: int, order: int} $candidate + * @param array{quality: float, specificity: int, order: int} $current + */ + private function isMoreSpecific(array $candidate, array $current): bool + { + if ($candidate['specificity'] !== $current['specificity']) { + return $candidate['specificity'] > $current['specificity']; + } + + if ($candidate['quality'] !== $current['quality']) { + return $candidate['quality'] > $current['quality']; + } + + return $candidate['order'] < $current['order']; + } + + /** + * @param array{quality: float, specificity: int, order: int} $candidate + * @param array{name: string, quality: float, specificity: int, order: int} $current + */ + private function isPreferred(array $candidate, array $current): bool + { + if ($candidate['quality'] !== $current['quality']) { + return $candidate['quality'] > $current['quality']; + } + + if ($candidate['specificity'] !== $current['specificity']) { + return $candidate['specificity'] > $current['specificity']; + } + + return $candidate['order'] < $current['order']; + } + + private function parseQuality(string $value): float + { + if (preg_match('/^(?:0(?:\.\d{0,3})?|1(?:\.0{0,3})?)$/D', $value) !== 1) { + return 0.0; + } + + return (float) $value; + } +} diff --git a/tests/Browser/SseClient.test.mjs b/tests/Browser/SseClient.test.mjs index b4699a1..0ab1d87 100644 --- a/tests/Browser/SseClient.test.mjs +++ b/tests/Browser/SseClient.test.mjs @@ -2,6 +2,10 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import { + DirectSseAdapter, + InMemorySseAdapter, + MercureSseAdapter, + RedisSseAdapter, SseClient, SseClientStatus, } from '../../resources/js/sse-client.js'; @@ -35,18 +39,12 @@ class FakeEventSource { } } -const directBootstrap = async () => ({ - ok: true, - status: 200, - json: async () => ({ url: null, expiresAt: null }), -}); - const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); const wait = (milliseconds) => new Promise((resolve) => { setTimeout(resolve, milliseconds); }); -test('builds the URL, dispatches envelopes, reports status, and closes', async () => { +test('uses the direct adapter by default', () => { const source = new FakeEventSource(); const statuses = []; const named = []; @@ -58,7 +56,6 @@ test('builds the URL, dispatches envelopes, reports status, and closes', async ( endpoint: 'https://example.test/sse?locale=bs', channels: ['users.42', 'orders.918', 'users.42'], query: { tenant: 7 }, - fetchFactory: directBootstrap, eventSourceFactory: (url, options) => { receivedUrl = url; receivedOptions = options; @@ -73,8 +70,7 @@ test('builds the URL, dispatches envelopes, reports status, and closes', async ( .onMessage((message) => globalMessages.push(message)) .connect(); - await nextTurn(); - + assert.equal(client.adapter instanceof DirectSseAdapter, true); assert.equal(Object.hasOwn(client, 'transport'), false); const url = new URL(receivedUrl); assert.equal(url.searchParams.get('channels'), 'users.42,orders.918'); @@ -119,17 +115,29 @@ test('builds the URL, dispatches envelopes, reports status, and closes', async ( ]); }); -test('preserves invalid JSON and invokes unsupported fallback once', async () => { +test('provides semantic direct adapters for built-in PHP stream brokers', () => { + const redis = new RedisSseAdapter(); + const memory = new InMemorySseAdapter(); + + assert.deepEqual( + redis.resolve({ url: 'https://example.test/sse' }), + { url: 'https://example.test/sse', expiresAt: null }, + ); + assert.deepEqual( + memory.resolve({ url: 'https://example.test/sse' }), + { url: 'https://example.test/sse', expiresAt: null }, + ); +}); + +test('preserves invalid JSON and invokes unsupported fallback once', () => { const source = new FakeEventSource(); const messages = []; const client = new SseClient({ endpoint: 'https://example.test/sse', - fetchFactory: directBootstrap, eventSourceFactory: () => source, }); client.onMessage((message) => messages.push(message)).connect(); - await nextTurn(); source.dispatch('message', { data: 'not-json', lastEventId: '' }); assert.equal(messages[0].parsed, false); @@ -150,7 +158,7 @@ test('preserves invalid JSON and invokes unsupported fallback once', async () => assert.equal(fallbackCalls, 1); }); -test('subscribes and unsubscribes channels by reconnecting active sources', async () => { +test('subscribes and unsubscribes channels by reconnecting active sources', () => { const sources = []; const urls = []; const statuses = []; @@ -158,7 +166,7 @@ test('subscribes and unsubscribes channels by reconnecting active sources', asyn const client = new SseClient({ endpoint: 'https://example.test/sse', channels: ['public.news'], - fetchFactory: directBootstrap, + adapter: new RedisSseAdapter(), eventSourceFactory: (url) => { const source = new FakeEventSource(); @@ -170,12 +178,10 @@ test('subscribes and unsubscribes channels by reconnecting active sources', asyn }); client.on('status', ({ status }) => statuses.push(status)).connect(); - await nextTurn(); sources[0].readyState = 1; sources[0].dispatch('open', { type: 'open' }); client.subscribe(['users.42', 'public.news']); - await nextTurn(); assert.equal(sources.length, 2); assert.equal(sources[0].closed, true); @@ -189,7 +195,6 @@ test('subscribes and unsubscribes channels by reconnecting active sources', asyn sources[1].dispatch('open', { type: 'open' }); client.unsubscribe('public.news'); - await nextTurn(); assert.equal(sources.length, 3); assert.equal(sources[1].closed, true); @@ -211,225 +216,7 @@ test('subscribes and unsubscribes channels by reconnecting active sources', asyn ]); }); -test('restarts an in-flight bootstrap when channels change', async () => { - const bootstrapUrls = []; - const signals = []; - const sourceUrls = []; - const client = new SseClient({ - endpoint: 'https://example.test/sse', - channels: ['public.news'], - fetchFactory: async (url, { signal }) => { - bootstrapUrls.push(url); - signals.push(signal); - - if (bootstrapUrls.length === 1) { - return new Promise((resolve, reject) => { - signal.addEventListener('abort', () => { - reject(new Error('aborted')); - }, { once: true }); - }); - } - - return directBootstrap(); - }, - eventSourceFactory: (url) => { - sourceUrls.push(url); - - return new FakeEventSource(); - }, - }); - - client.connect(); - client.subscribe('users.42'); - await nextTurn(); - - assert.equal(bootstrapUrls.length, 2); - assert.equal(signals[0].aborted, true); - assert.equal(sourceUrls.length, 1); - assert.equal( - new URL(sourceUrls[0]).searchParams.get('channels'), - 'public.news,users.42', - ); - - client.close(); -}); - -test('close aborts an in-flight bootstrap without opening EventSource', async () => { - let bootstrapSignal; - let eventSourceCalls = 0; - let fallbackCalls = 0; - const client = new SseClient({ - endpoint: 'https://example.test/sse', - fetchFactory: async (url, { signal }) => { - bootstrapSignal = signal; - - return new Promise((resolve, reject) => { - signal.addEventListener('abort', () => { - reject(new Error('aborted')); - }, { once: true }); - }); - }, - eventSourceFactory: () => { - eventSourceCalls++; - - return new FakeEventSource(); - }, - fallback: () => { - fallbackCalls++; - }, - }); - - client.connect(); - client.close(); - await nextTurn(); - - assert.equal(bootstrapSignal.aborted, true); - assert.equal(eventSourceCalls, 0); - assert.equal(fallbackCalls, 0); - assert.equal(client.status, SseClientStatus.CLOSED); -}); - -test('retries a transient bootstrap failure', async () => { - let bootstrapCalls = 0; - const sources = []; - const fallbackReasons = []; - const client = new SseClient({ - endpoint: 'https://example.test/sse', - channels: ['public.news'], - fetchFactory: async () => { - bootstrapCalls++; - - if (bootstrapCalls === 1) { - throw new Error('temporary network failure'); - } - - return directBootstrap(); - }, - eventSourceFactory: () => { - const source = new FakeEventSource(); - sources.push(source); - - return source; - }, - fallback: ({ reason }) => fallbackReasons.push(reason), - }); - - client.connect(); - await nextTurn(); - - assert.equal(client.status, SseClientStatus.RECONNECTING); - assert.equal(bootstrapCalls, 1); - - await wait(1100); - await nextTurn(); - - assert.equal(bootstrapCalls, 2); - assert.equal(sources.length, 1); - assert.equal(client.status, SseClientStatus.CONNECTING); - assert.deepEqual(fallbackReasons, ['bootstrap-error']); - - client.close(); -}); - -test('refreshes an expiring stream even before EventSource opens', async () => { - let bootstrapCalls = 0; - const sources = []; - const client = new SseClient({ - endpoint: 'https://example.test/sse', - channels: ['public.news'], - fetchFactory: async () => { - bootstrapCalls++; - - return { - ok: true, - status: 200, - json: async () => ({ - url: null, - expiresAt: Math.floor(Date.now() / 1000) + 30, - }), - }; - }, - eventSourceFactory: () => { - const source = new FakeEventSource(); - sources.push(source); - - return source; - }, - }); - - client.connect(); - await nextTurn(); - await wait(1100); - await nextTurn(); - - assert.equal(bootstrapCalls, 2); - assert.equal(sources.length, 2); - assert.equal(sources[0].closed, true); - assert.equal(client.status, SseClientStatus.CONNECTING); - - client.close(); -}); - -test('rejects expired bootstrap authorization without opening EventSource', async () => { - let eventSourceCalls = 0; - const fallbackReasons = []; - const client = new SseClient({ - endpoint: 'https://example.test/sse', - channels: ['public.news'], - fetchFactory: async () => ({ - ok: true, - status: 200, - json: async () => ({ - url: null, - expiresAt: Math.floor(Date.now() / 1000) - 1, - }), - }), - eventSourceFactory: () => { - eventSourceCalls++; - - return new FakeEventSource(); - }, - fallback: ({ reason }) => fallbackReasons.push(reason), - }); - - client.connect(); - await nextTurn(); - - assert.equal(eventSourceCalls, 0); - assert.equal(client.status, SseClientStatus.CLOSED); - assert.deepEqual(fallbackReasons, ['bootstrap-error']); -}); - -test('clamps long-lived stream refresh timers', async () => { - let bootstrapCalls = 0; - const client = new SseClient({ - endpoint: 'https://example.test/sse', - channels: ['public.news'], - fetchFactory: async () => { - bootstrapCalls++; - - return { - ok: true, - status: 200, - json: async () => ({ - url: null, - expiresAt: Number.MAX_SAFE_INTEGER, - }), - }; - }, - eventSourceFactory: () => new FakeEventSource(), - }); - - client.connect(); - await nextTurn(); - await wait(20); - - assert.equal(bootstrapCalls, 1); - - client.close(); -}); - -test('uses the server bootstrap to open EventSource directly on the Hub', async () => { +test('resolves Mercure authorization and opens EventSource directly on the Hub', async () => { const source = new FakeEventSource(); const fetchCalls = []; let receivedHubUrl; @@ -437,25 +224,25 @@ test('uses the server bootstrap to open EventSource directly on the Hub', async const client = new SseClient({ endpoint: 'https://app.example.test/sse', - channels: ['users.42', 'projects.7'], - fetchFactory: async (url, options) => { - fetchCalls.push({ url, options }); - - return { - ok: true, - status: 200, - json: async () => ({ - url: 'https://hub.example.test/.well-known/mercure?custom=1', - query: { - topic: [ + adapter: new MercureSseAdapter({ + fetchFactory: async (url, options) => { + fetchCalls.push({ url, options }); + + return { + ok: true, + status: 200, + json: async () => ({ + hub: 'https://hub.example.test/.well-known/mercure?custom=1', + topics: [ 'urn:example:sse:users.42', 'urn:example:sse:projects.7', ], - }, - expiresAt: null, - }), - }; - }, + expiresAt: null, + }), + }; + }, + }), + channels: ['users.42', 'projects.7'], eventSourceFactory: (url, options) => { receivedHubUrl = url; receivedOptions = options; @@ -494,45 +281,72 @@ test('uses the server bootstrap to open EventSource directly on the Hub', async client.close(); }); -test('reports bootstrap failures without opening EventSource', async () => { - let eventSourceCalls = 0; - const fallbackReasons = []; +test('aborts stale Mercure authorization when channels change', async () => { + const authorizationUrls = []; + const signals = []; + const sourceUrls = []; + const adapter = new MercureSseAdapter({ + fetchFactory: async (url, { signal }) => { + authorizationUrls.push(url); + signals.push(signal); + + if (authorizationUrls.length === 1) { + return new Promise((resolve, reject) => { + signal.addEventListener('abort', () => { + reject(new Error('aborted')); + }, { once: true }); + }); + } + + return { + ok: true, + status: 200, + json: async () => ({ + hub: 'https://hub.example.test/.well-known/mercure', + topics: ['urn:example:sse:public.news', 'urn:example:sse:users.42'], + expiresAt: null, + }), + }; + }, + }); + const client = new SseClient({ - endpoint: 'https://app.example.test/sse', - channels: ['users.42'], - fetchFactory: async () => ({ - ok: false, - status: 403, - json: async () => ({}), - }), - eventSourceFactory: () => { - eventSourceCalls++; + endpoint: 'https://example.test/sse', + channels: ['public.news'], + adapter, + eventSourceFactory: (url) => { + sourceUrls.push(url); return new FakeEventSource(); }, - fallback: ({ reason }) => fallbackReasons.push(reason), }); client.connect(); + client.subscribe('users.42'); await nextTurn(); - assert.equal(eventSourceCalls, 0); - assert.equal(client.status, SseClientStatus.CLOSED); - assert.deepEqual(fallbackReasons, ['bootstrap-error']); + assert.equal(authorizationUrls.length, 2); + assert.equal(signals[0].aborted, true); + assert.equal(sourceUrls.length, 1); + assert.deepEqual( + new URL(sourceUrls[0]).searchParams.getAll('topic'), + ['urn:example:sse:public.news', 'urn:example:sse:users.42'], + ); + + client.close(); }); -test('rejects invalid bootstrap connection data', async () => { +test('reports adapter failures without opening EventSource', async () => { let eventSourceCalls = 0; const fallbackReasons = []; const client = new SseClient({ endpoint: 'https://app.example.test/sse', channels: ['users.42'], - fetchFactory: async () => ({ - ok: true, - status: 200, - json: async () => ({ - url: 'javascript:alert(1)', - expiresAt: null, + adapter: new MercureSseAdapter({ + fetchFactory: async () => ({ + ok: false, + status: 403, + json: async () => ({}), }), }), eventSourceFactory: () => { @@ -548,23 +362,65 @@ test('rejects invalid bootstrap connection data', async () => { assert.equal(eventSourceCalls, 0); assert.equal(client.status, SseClientStatus.CLOSED); - assert.deepEqual(fallbackReasons, ['bootstrap-error']); + assert.deepEqual(fallbackReasons, ['adapter-error']); }); -test('keeps EventSource construction errors distinct from bootstrap errors', async () => { +test('closes an expiring adapter connection before refreshing it', async () => { + let authorizationCalls = 0; + const sources = []; + const client = new SseClient({ + endpoint: 'https://example.test/sse', + channels: ['public.news'], + adapter: new MercureSseAdapter({ + fetchFactory: async () => { + authorizationCalls++; + + return { + ok: true, + status: 200, + json: async () => ({ + hub: 'https://hub.example.test/.well-known/mercure', + topics: ['urn:example:sse:public.news'], + expiresAt: Math.floor(Date.now() / 1000) + 30, + }), + }; + }, + }), + eventSourceFactory: () => { + const source = new FakeEventSource(); + sources.push(source); + + return source; + }, + }); + + client.connect(); + await nextTurn(); + await wait(1100); + await nextTurn(); + + assert.equal(authorizationCalls, 2); + assert.equal(sources.length, 2); + assert.equal(sources[0].closed, true); + assert.equal(client.status, SseClientStatus.RECONNECTING); + + client.close(); +}); + +test('keeps EventSource construction errors distinct from adapter errors', async () => { const fallbackReasons = []; const client = new SseClient({ endpoint: 'https://app.example.test/sse', channels: ['users.42'], - fetchFactory: async () => ({ - ok: true, - status: 200, - json: async () => ({ - url: 'https://hub.example.test/.well-known/mercure', - query: { - topic: ['urn:example:sse:users.42'], - }, - expiresAt: null, + adapter: new MercureSseAdapter({ + fetchFactory: async () => ({ + ok: true, + status: 200, + json: async () => ({ + hub: 'https://hub.example.test/.well-known/mercure', + topics: ['urn:example:sse:users.42'], + expiresAt: null, + }), }), }), eventSourceFactory: () => { diff --git a/tests/Browser/package-export.test.mjs b/tests/Browser/package-export.test.mjs index 8ef98b3..d8b771c 100644 --- a/tests/Browser/package-export.test.mjs +++ b/tests/Browser/package-export.test.mjs @@ -2,11 +2,17 @@ import assert from 'node:assert/strict'; import { test } from 'node:test'; import SseClientDefault, { + MercureSseAdapter, + RedisSseAdapter, SseClient, SseClientStatus, } from '@maniaba/codeigniter4-sse-browser'; +import MercureSseAdapterDefault from '@maniaba/codeigniter4-sse-browser/adapters/mercure-sse-adapter.js'; +import RedisSseAdapterDefault from '@maniaba/codeigniter4-sse-browser/adapters/redis-sse-adapter.js'; test('exports the browser client as an npm package entrypoint', () => { assert.equal(SseClientDefault, SseClient); assert.equal(SseClientStatus.IDLE, 'idle'); + assert.equal(RedisSseAdapterDefault, RedisSseAdapter); + assert.equal(MercureSseAdapterDefault, MercureSseAdapter); }); diff --git a/tests/HTTP/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 16859ff..5dcd0ad 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -38,7 +38,7 @@ public function testRejectsUnsupportedAcceptHeader(): void $this->assertStringContainsString('not_acceptable', $body); } - public function testLocalRouteReturnsBrowserBootstrapWithoutOpeningAPhpStream(): void + public function testLocalRouteRejectsJsonAcceptHeader(): void { $manager = new SseConnectionManager( new RecordingSubscriber(), @@ -59,15 +59,11 @@ public function testLocalRouteReturnsBrowserBootstrapWithoutOpeningAPhpStream(): ); $body = $result->getBody(); - $this->assertSame(200, $result->getStatusCode()); + $this->assertSame(406, $result->getStatusCode()); $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); - $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); $this->assertSame('Accept', $result->getHeaderLine('Vary')); $this->assertIsString($body); - $this->assertSame( - ['url' => null, 'expiresAt' => null], - json_decode($body, true, 512, JSON_THROW_ON_ERROR), - ); + $this->assertStringContainsString('not_acceptable', $body); } finally { $superglobals->setGetArray($previousGet); } @@ -183,16 +179,14 @@ public function testMercureRouteAuthorizesChannelsWithoutOpeningAPhpStream(): vo $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); $this->assertSame( 'https://example.test/.well-known/mercure', - $decoded['url'], + $decoded['hub'], ); $this->assertSame( [ - 'topic' => [ - 'urn:example:sse:public.news', - 'urn:example:sse:public.status', - ], + 'urn:example:sse:public.news', + 'urn:example:sse:public.status', ], - $decoded['query'], + $decoded['topics'], ); $this->assertIsInt($decoded['expiresAt']); diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php index eaaf8c4..f6e4560 100644 --- a/tests/HTTP/SubscriptionEndpointTest.php +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -34,6 +34,9 @@ public function testLocalEndpointRejectsMissingEventStreamAcceptHeader(): void $this->assertInstanceOf(ResponseInterface::class, $result); $this->assertSame(406, $result->getStatusCode()); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); + $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); $this->assertStringContainsString('not_acceptable', (string) $result->getBody()); } @@ -67,75 +70,6 @@ public static function provideLocalEndpointAcceptsEventStreamCompatibleRequests( yield 'accept header disabled' => [null, false]; } - public function testLocalEndpointReturnsBrowserBootstrapForJsonRequests(): void - { - [$request, $response] = $this->http('application/json'); - $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); - - $this->assertNull($endpoint->preflight($request, $response)); - - $result = $endpoint->respond($request, $response, ['public.news']); - $body = $result->getBody(); - - $this->assertSame(200, $result->getStatusCode()); - $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); - $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); - $this->assertSame('Accept', $result->getHeaderLine('Vary')); - $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); - $this->assertIsString($body); - $this->assertSame( - ['url' => null, 'expiresAt' => null], - json_decode($body, true, 512, JSON_THROW_ON_ERROR), - ); - } - - #[DataProvider('provideLocalEndpointHonorsPreferredRepresentation')] - public function testLocalEndpointHonorsPreferredRepresentation( - string $accept, - bool $expectsBootstrap, - ): void { - [$request, $response] = $this->http($accept); - $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); - - $this->assertNull($endpoint->preflight($request, $response)); - - $result = $endpoint->respond($request, $response, ['public.news']); - - if ($expectsBootstrap) { - $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); - - return; - } - - $this->assertInstanceOf(LegacySseResponse::class, $result); - } - - /** - * @return iterable - */ - public static function provideLocalEndpointHonorsPreferredRepresentation(): iterable - { - yield 'stream has higher quality' => [ - 'application/json;q=0.1, text/event-stream;q=1', - false, - ]; - - yield 'bootstrap has higher quality' => [ - 'application/json;q=1, text/event-stream;q=0.1', - true, - ]; - - yield 'stream wins equal quality by order' => [ - 'text/event-stream, application/json', - false, - ]; - - yield 'bootstrap wins equal quality by order' => [ - 'application/json, text/event-stream', - true, - ]; - } - #[DataProvider('provideLocalEndpointRejectsUnacceptableEventStreamRequests')] public function testLocalEndpointRejectsUnacceptableEventStreamRequests(string $accept): void { @@ -153,15 +87,23 @@ public function testLocalEndpointRejectsUnacceptableEventStreamRequests(string $ */ public static function provideLocalEndpointRejectsUnacceptableEventStreamRequests(): iterable { + yield 'JSON only' => ['application/json']; + yield 'event stream q zero' => ['text/event-stream;q=0']; yield 'wildcard q zero' => ['*/*;q=0']; - yield 'bootstrap q zero' => ['application/json;q=0']; + yield 'event stream excluded despite wildcard' => [ + 'text/event-stream;q=0, */*;q=1', + ]; - yield 'specific q zero wins over wildcard' => ['text/event-stream;q=0, */*;q=1']; + yield 'text excluded despite wildcard' => [ + 'text/*;q=0, */*;q=1', + ]; - yield 'text wildcard q zero wins over wildcard' => ['text/*;q=0, */*;q=1']; + yield 'malformed quality values' => [ + 'text/event-stream;q=2', + ]; } public function testLocalEndpointCreatesStreamingResponse(): void @@ -222,10 +164,10 @@ public function testMercureEndpointReturnsBootstrapPayloadAndCookie(): void $this->assertIsString($body); $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); - $this->assertSame('https://example.test/.well-known/mercure', $decoded['url']); + $this->assertSame('https://example.test/.well-known/mercure', $decoded['hub']); $this->assertSame( - ['topic' => ['urn:example:sse:public.news']], - $decoded['query'], + ['urn:example:sse:public.news'], + $decoded['topics'], ); $this->assertIsInt($decoded['expiresAt']); @@ -235,6 +177,66 @@ public function testMercureEndpointReturnsBootstrapPayloadAndCookie(): void $this->assertTrue($cookie->isHTTPOnly()); } + #[DataProvider('provideMercureEndpointAcceptsJsonCompatibleRequests')] + public function testMercureEndpointAcceptsJsonCompatibleRequests(?string $accept): void + { + [$request, $response] = $this->http($accept); + $endpoint = new MercureSubscriptionEndpoint($this->mercureConfig()); + + $this->assertNull($endpoint->preflight($request, $response)); + } + + /** + * @return iterable + */ + public static function provideMercureEndpointAcceptsJsonCompatibleRequests(): iterable + { + yield 'missing header' => [null]; + + yield 'JSON' => ['application/json']; + + yield 'application wildcard' => ['application/*']; + + yield 'wildcard' => ['*/*']; + + yield 'JSON fallback behind unsupported preferred type' => [ + 'text/event-stream, */*;q=0.5', + ]; + } + + #[DataProvider('provideMercureEndpointRejectsJsonExclusions')] + public function testMercureEndpointRejectsJsonExclusions(string $accept): void + { + [$request, $response] = $this->http($accept); + $endpoint = new MercureSubscriptionEndpoint($this->mercureConfig()); + + $result = $endpoint->preflight($request, $response); + + $this->assertInstanceOf(ResponseInterface::class, $result); + $this->assertSame(406, $result->getStatusCode()); + $this->assertNull($result->getCookie('mercureAuthorization')); + $this->assertStringContainsString('no-store', $result->getHeaderLine('Cache-Control')); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); + $this->assertSame('nosniff', $result->getHeaderLine('X-Content-Type-Options')); + $this->assertStringContainsString('not_acceptable', (string) $result->getBody()); + } + + /** + * @return iterable + */ + public static function provideMercureEndpointRejectsJsonExclusions(): iterable + { + yield 'event stream only' => ['text/event-stream']; + + yield 'unsupported media type' => ['text/html']; + + yield 'JSON q zero' => ['application/json;q=0']; + + yield 'explicit JSON exclusion wins over wildcard' => [ + 'application/json;q=0, */*;q=1', + ]; + } + public function testMercureEndpointDeletesCookieWhenSubscriberAuthorizationIsDisabled(): void { [$request, $response] = $this->http();