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/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..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,6 +100,9 @@ live.on('status', ({ status }) => { live.connect(); ``` +`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. With Mercure, the browser streams directly from the Hub. @@ -193,13 +200,21 @@ 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', - transport: 'mercure', + adapter: new MercureSseAdapter(), channels: [`users.${currentUserId}`], }); ``` +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, cookies, CORS, and reverse-proxy configuration. 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/architecture.md b/docs/architecture.md index bcd45ef..3477a25 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 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 Normal application code uses the high-level service: diff --git a/docs/browser-client.md b/docs/browser-client.md index cae91d0..c8444e0 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 wraps native +browser `EventSource`. Broker-specific stream resolution lives in small +adapter classes. It provides: @@ -11,13 +12,12 @@ It provides: - safe JSON parsing; - channel and custom query parameters; - credential configuration; -- 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. -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. ## Import @@ -32,6 +32,7 @@ Then import it through the package export: ```javascript import { + RedisSseAdapter, SseClient, SseClientStatus, } from '@maniaba/codeigniter4-sse-browser'; @@ -59,6 +60,7 @@ published package assets: ```javascript import { + RedisSseAdapter, SseClient, SseClientStatus, } from '/vendor/codeigniter4-sse/sse-client.js'; @@ -74,23 +76,24 @@ 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, source: 'orders-page', }, withCredentials: true, - transport: 'eventsource', fallback: null, }); ``` @@ -98,13 +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` | 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 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 Mercure authorization request. | Array query values are appended as repeated parameters. `null` and `undefined` object values are omitted. The `channels` option wins over an existing @@ -112,29 +114,64 @@ object values are omitted. The `channels` option wins over an existing Do not use query parameters for bearer tokens or secrets. -## Mercure transport +## Adapters -When Mercure is the configured broker, `endpoint` is the short CodeIgniter -authorization route rather than the Hub stream itself: +Choose the adapter that matches the configured server broker: ```javascript +import { + RedisSseAdapter, + SseClient, +} from '@maniaba/codeigniter4-sse-browser'; + const live = new SseClient({ endpoint: '/sse', - transport: 'mercure', + adapter: new RedisSseAdapter(), + channels: ['users.42'], +}); +``` + +`RedisSseAdapter`, `LocalSseAdapter`, and `InMemorySseAdapter` are semantic +direct adapters. They open EventSource against `endpoint` after `SseClient` +adds channels and query parameters. + +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: @@ -142,6 +179,7 @@ Initial channels are passed to the constructor: ```javascript const live = new SseClient({ endpoint: '/sse', + adapter: new RedisSseAdapter(), channels: ['users.42'], }); ``` @@ -161,9 +199,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, 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 @@ -211,8 +249,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 @@ -333,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') { @@ -365,30 +404,33 @@ 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. +`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. + 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. +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, the server must return the exact allowed origin and -`Access-Control-Allow-Credentials: true`. Cookie `Domain`, `Secure`, and +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. Standard browser EventSource does not accept arbitrary request headers. diff --git a/docs/channels-and-authorization.md b/docs/channels-and-authorization.md index 11ac189..76e6e5f 100644 --- a/docs/channels-and-authorization.md +++ b/docs/channels-and-authorization.md @@ -133,9 +133,10 @@ 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, +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: @@ -203,7 +204,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 @@ -212,9 +215,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 @@ -224,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 e7bbf49..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 the PHP stream transport. | +| `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`. 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`. 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 @@ -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,25 @@ 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 exactly one of `factory` or `adapter`: + +- `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. +See [Custom brokers](custom-brokers.md) for implementation examples and +troubleshooting. ```php -use App\Sse\CustomPublisher; -use App\Sse\CustomSubscriber; +use App\Sse\CustomBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\InMemory\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Null\NullBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Redis\RedisBrokerAdapterFactory; final class Sse extends BaseSse { @@ -192,47 +205,26 @@ 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, ], ]; } ``` -When a broker needs application services or constructor arguments, use factory -closures: - -```php -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'), - ], -]; -``` - ## Mercure Mercure options live in one array, parallel to Redis: @@ -268,8 +260,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 +285,7 @@ public array $redis = [ 'maxPayloadBytes' => 1_048_576, 'maxResponseElements' => 1024, 'maxResponseDepth' => 8, + 'allowPatternSubscriptions' => false, 'clientName' => null, 'streamContext' => [], ]; @@ -312,10 +305,11 @@ 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. | +| `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/custom-brokers.md b/docs/custom-brokers.md new file mode 100644 index 0000000..0577c4b --- /dev/null +++ b/docs/custom-brokers.md @@ -0,0 +1,388 @@ +# 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\Config\Sse; + +final class AcmeConfigFactory +{ + 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), + ); + } + + 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 : []; + } +} +``` + +## 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. The PHP stream manager encodes browser SSE payloads itself; +the serializer is for broker transport payloads. + +## 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 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; +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([ + 'endpoint' => $this->publicEndpoint, + 'channels' => $channels, + 'expiresAt' => null, + ]) + ->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. 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 + +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\Local\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->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 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()`. | +| `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/deployment.md b/docs/deployment.md index 475d181..8ef7863 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. 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` 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 ea0f632..7ee7967 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; @@ -47,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}`], }); @@ -61,9 +66,15 @@ live.on('notification.created', ({ data }) => { live.connect(); ``` +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 [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/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 6538bd5..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,14 +160,17 @@ publisher decorator used for every broker. ## Browser client -Set the client transport to `mercure`: +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', - transport: 'mercure', + adapter: new MercureSseAdapter(), channels: [`users.${currentUserId}`], withCredentials: true, }); @@ -191,7 +194,6 @@ 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"], "expiresAt": 1785520800 @@ -204,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 @@ -218,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: @@ -305,6 +311,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..b7d2d02 100644 --- a/docs/module-structure.md +++ b/docs/module-structure.md @@ -7,13 +7,19 @@ and browser behavior separate. src/ ├── Authorization/ ├── Broker/ +│ ├── InMemory/ +│ ├── Local/ │ ├── Mercure/ +│ ├── Null/ │ └── Redis/ ├── Commands/ ├── Config/ ├── Contracts/ +├── Endpoint/ ├── Event/ ├── Exception/ +├── Factory/ +├── Health/ ├── HTTP/ ├── Stream/ └── Support/ @@ -31,6 +37,9 @@ For typed integrations, depend on: - `PublisherInterface` - `SubscriberInterface` +- `BrokerAdapterInterface` +- `BrokerAdapterFactoryInterface` +- `SubscriptionEndpointInterface` - `ChannelAuthorizerInterface` - `UserResolverInterface` - `EventInterface` @@ -43,17 +52,28 @@ 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. +`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. + +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, -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. It also provides the short JSON +descriptor that points the browser back to that stream. Broker-specific +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: @@ -66,12 +86,15 @@ 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 -`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` 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 7c1de45..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,7 +150,7 @@ live.on('status', ({ status }) => { live.connect(); ``` -The client requests: +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 2cd185b..6e4c727 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(); ``` @@ -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`; +- 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,14 +152,20 @@ Use recording implementations of `SubscriberInterface` and ## Browser client tests -`SseClient` accepts `eventSourceFactory` specifically so tests can supply a -small fake: +`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'], eventSourceFactory: (url, options) => { expect(url).toContain('channels=public.test'); diff --git a/docs/troubleshooting.md b/docs/troubleshooting.md index b1119df..c475fe1 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 Redis configuration requires this header for a direct PHP stream +request: ```http Accept: text/event-stream @@ -83,6 +84,34 @@ 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 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: + +```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. @@ -121,22 +150,23 @@ 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 +`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`. -## Mercure client reports authorization-error +## Browser client reports adapter-error -The browser client could not complete 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` usually means Mercure signing configuration is incomplete; +- `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 Hub. +This error occurs before EventSource connects to the external Hub. ## The connection opens but no events arrive 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`. 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 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 f499596..1a1f1a2 100644 --- a/resources/js/sse-client.d.ts +++ b/resources/js/sse-client.d.ts @@ -29,7 +29,71 @@ export type SseQuery = export type SseChannelInput = string | readonly string[]; -export type SseTransport = 'eventsource' | 'mercure'; +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. @@ -106,7 +170,7 @@ export type SseFallbackReason = | 'unsupported' | 'construction-error' | 'connection-error' - | 'authorization-error'; + | 'adapter-error'; export interface SseFallbackContext { readonly reason: SseFallbackReason; @@ -147,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. */ @@ -163,13 +232,7 @@ export interface SseClientOptions { 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 adapter, connection, or browser errors. */ readonly fallback?: SseFallback | null; @@ -177,21 +240,16 @@ export interface SseClientOptions { * Optional EventSource factory, mainly useful for tests. */ readonly eventSourceFactory?: SseEventSourceFactory | null; - - /** - * Optional Fetch-compatible factory used by the Mercure authorization step. - */ - 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; - readonly transport: SseTransport; /** * Current lifecycle status. @@ -270,9 +328,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. + * 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 03624a8..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', @@ -36,13 +43,12 @@ const RESERVED_NATIVE_EVENTS = new Set(['open', 'error']); /** * @typedef {Object} SseClientOptions * @property {string} endpoint + * @property {Object|null} [adapter] * @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] */ export class SseClient { @@ -51,13 +57,12 @@ export class SseClient { */ constructor({ endpoint, + adapter = new DirectSseAdapter(), channels = [], query = {}, withCredentials = true, - transport = 'eventsource', fallback = null, eventSourceFactory = null, - fetchFactory = null, } = {}) { const queryIsUrlSearchParams = ( typeof globalThis !== 'undefined' @@ -78,14 +83,16 @@ export class SseClient { ); } - if (fallback !== null && typeof fallback !== 'function') { - throw new TypeError('SseClient fallback must be a function or null.'); + if ( + adapter === null + || typeof adapter !== 'object' + || typeof adapter.resolve !== 'function' + ) { + throw new TypeError('SseClient adapter must provide a resolve() method.'); } - if (!['eventsource', 'mercure'].includes(transport)) { - throw new TypeError( - 'SseClient transport must be "eventsource" or "mercure".', - ); + if (fallback !== null && typeof fallback !== 'function') { + throw new TypeError('SseClient fallback must be a function or null.'); } if ( @@ -97,22 +104,15 @@ 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); - this.transport = transport; this._queryIsUrlSearchParams = queryIsUrlSearchParams; this._fallback = fallback; this._eventSourceFactory = eventSourceFactory; - this._fetchFactory = fetchFactory; this._listeners = new Map(); this._nativeMessageHandlers = new Map(); this._source = null; @@ -297,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} */ @@ -306,7 +307,32 @@ export class SseClient { return this; } - if (this._source !== null) { + this._startConnection(); + + return this; + } + + /** + * 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' }); + + return this; + } + + _startConnection(preserveSource = false) { + this._cancelAdapter(); + this._clearRefreshTimer(); + + if (this._source !== null && !preserveSource) { this._teardownSource(); } @@ -335,21 +361,159 @@ export class SseClient { ); } - return this; + return; } - this._currentUrl = this._buildUrl(); - this._setStatus(SseClientStatus.CONNECTING); + const endpointUrl = this._buildUrl(); + this._currentUrl = endpointUrl; + this._setStatus(preserveSource + ? SseClientStatus.RECONNECTING + : SseClientStatus.CONNECTING); - if (this.transport === 'mercure') { - this._connectMercure(factory, generation); + let resolution; - return this; + try { + resolution = this.adapter.resolve({ + url: endpointUrl, + channels: [...this.channels], + withCredentials: this.withCredentials, + client: this, + }); + } catch (error) { + this._handleAdapterError(error, generation, preserveSource, true); + + return; } - this._openSource(factory, this._currentUrl); + 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; + } - return this; + 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 + ) { + return; + } + + 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, + }; } /** @@ -443,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} */ @@ -477,177 +626,63 @@ export class SseClient { } /** - * @returns {Function|null} + * @param {*} error + * @param {number} generation + * @param {boolean} preserveSource + * @param {boolean} throwOnUnhandled */ - _resolveFetchFactory() { - if (this._fetchFactory !== null) { - return this._fetchFactory; - } - + _handleAdapterError(error, generation, preserveSource, throwOnUnhandled) { if ( - typeof globalThis === 'undefined' - || typeof globalThis.fetch !== 'function' + generation !== this._connectionGeneration + || this._manuallyClosed ) { - return null; - } - - return globalThis.fetch.bind(globalThis); - } - - /** - * @param {Function} eventSourceFactory - * @param {number} generation - */ - _connectMercure(eventSourceFactory, generation) { - const fetchFactory = this._resolveFetchFactory(); - - if (fetchFactory === null) { - this._handleMercureAuthorizationError( - new Error('Fetch is required by the Mercure transport.'), - generation, - ); - 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.', - ); - } - - if (response.ok !== true) { - throw new Error( - `Mercure authorization failed with HTTP ${response.status ?? 0}.`, - ); - } - - return response.json(); - }) - .then((bootstrap) => { - if ( - generation !== this._connectionGeneration - || this._manuallyClosed - ) { - return; - } - - const authorization = this._normalizeMercureBootstrap(bootstrap); - const hubUrl = this._buildMercureHubUrl( - authorization.hub, - authorization.topics, - ); - - this._currentUrl = hubUrl; - const opened = this._openSource( - eventSourceFactory, - hubUrl, - false, - ); + if (preserveSource && this._source !== null) { + this._teardownSource(); + } else { + this._source = null; + } - if (opened) { - this._scheduleMercureRefresh( - authorization.expiresAt, - generation, - ); - } - }) - .catch((error) => { - this._handleMercureAuthorizationError(error, generation); - }); - } + this._setStatus(SseClientStatus.CLOSED, { + reason: 'adapter-error', + error, + }); - /** - * @param {*} bootstrap - * @returns {{hub: string, topics: string[], expiresAt: number|null}} - */ - _normalizeMercureBootstrap(bootstrap) { 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() === '' - )) - || ( - bootstrap.expiresAt !== null - && bootstrap.expiresAt !== undefined - && !Number.isInteger(bootstrap.expiresAt) - ) + generation !== this._connectionGeneration + || this._manuallyClosed ) { - throw new TypeError( - 'The Mercure authorization endpoint returned invalid bootstrap data.', - ); + return; } - return { - hub: bootstrap.hub.trim(), - topics: [...new Set( - bootstrap.topics.map((topic) => topic.trim()), - )], - expiresAt: Number.isInteger(bootstrap.expiresAt) - ? bootstrap.expiresAt - : null, - }; - } - - /** - * @param {string} hub - * @param {string[]} topics - * @returns {string} - */ - _buildMercureHubUrl(hub, topics) { - let url; + const handled = this._invokeFallback('adapter-error', null, error); - try { - url = new URL(hub); - } catch (error) { - throw new TypeError( - 'The Mercure Hub URL must be absolute.', - { cause: error }, - ); + if (!handled && throwOnUnhandled) { + throw error; } - url.searchParams.delete('topic'); - - for (const topic of topics) { - url.searchParams.append('topic', topic); + if (!handled) { + this._reportHandlerError(error); } - - url.hash = ''; - - return url.toString(); } /** * @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,42 +692,12 @@ export class SseClient { return; } - this._teardownSource(); - this.connect(); + this._startConnection(true); }, delay); this._refreshTimer?.unref?.(); } - /** - * @param {*} error - * @param {number} generation - */ - _handleMercureAuthorizationError(error, generation) { - if ( - generation !== this._connectionGeneration - || this._manuallyClosed - ) { - return; - } - - this._source = null; - this._setStatus(SseClientStatus.CLOSED, { - reason: 'authorization-error', - error, - }); - - const handled = this._invokeFallback( - 'authorization-error', - null, - error, - ); - - if (!handled) { - this._reportHandlerError(error); - } - } - /** * @returns {boolean} */ @@ -700,10 +705,7 @@ export class SseClient { return ( ( this._source !== null - || ( - this.transport === 'mercure' - && this._status === SseClientStatus.CONNECTING - ) + || this._status === SseClientStatus.CONNECTING ) && this._status !== SseClientStatus.CLOSED && this._status !== SseClientStatus.UNSUPPORTED @@ -716,6 +718,7 @@ export class SseClient { } this._connectionGeneration++; + this._cancelAdapter(); this._teardownSource(); if (this.channels.length === 0) { @@ -726,7 +729,7 @@ export class SseClient { return; } - this.connect(); + this._startConnection(); } /** @@ -1061,6 +1064,12 @@ export class SseClient { this._refreshTimer = null; } + _cancelAdapter() { + if (typeof this.adapter.cancel === 'function') { + this.adapter.cancel(); + } + } + /** * @param {string} eventName * @param {*} payload 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/InMemoryBroker.php b/src/Broker/InMemory/InMemoryBroker.php similarity index 67% rename from src/Broker/InMemoryBroker.php rename to src/Broker/InMemory/InMemoryBroker.php index fc2df93..3fd4d14 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; @@ -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/InMemory/InMemoryBrokerAdapterFactory.php b/src/Broker/InMemory/InMemoryBrokerAdapterFactory.php new file mode 100644 index 0000000..30e88c1 --- /dev/null +++ b/src/Broker/InMemory/InMemoryBrokerAdapterFactory.php @@ -0,0 +1,19 @@ +create($config, $context); + } +} diff --git a/src/Broker/Local/LocalBrokerAdapter.php b/src/Broker/Local/LocalBrokerAdapter.php new file mode 100644 index 0000000..2a9ae95 --- /dev/null +++ b/src/Broker/Local/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/Local/LocalBrokerAdapterFactory.php b/src/Broker/Local/LocalBrokerAdapterFactory.php new file mode 100644 index 0000000..09cada4 --- /dev/null +++ b/src/Broker/Local/LocalBrokerAdapterFactory.php @@ -0,0 +1,51 @@ +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->events, + SseConnectionOptions::fromConfig($config), + ); + + 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..40d36d5 --- /dev/null +++ b/src/Broker/Mercure/MercureBrokerAdapter.php @@ -0,0 +1,44 @@ +publisher; + } + + public function subscriptionEndpoint(): SubscriptionEndpointInterface + { + return $this->endpoint; + } + + public function healthCheck(): HealthCheckResult + { + if (! ($this->hasCurl ?? 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..f323ec0 --- /dev/null +++ b/src/Broker/Mercure/MercureBrokerAdapterFactory.php @@ -0,0 +1,35 @@ +configs ?? new MercureConfigFactory(); + $mercure = $configs->create($config); + + return new MercureBrokerAdapter( + $mercure, + new MercurePublisher($mercure, $context->serializer), + new MercureSubscriptionEndpoint( + $config, + new MercureSubscriptionFactory(mercure: $mercure), + mercure: $mercure, + ), + ); + } +} diff --git a/src/Factory/MercureConfigFactory.php b/src/Broker/Mercure/MercureConfigFactory.php similarity index 89% rename from src/Factory/MercureConfigFactory.php rename to src/Broker/Mercure/MercureConfigFactory.php index 5d558a6..e65dab7 100644 --- a/src/Factory/MercureConfigFactory.php +++ b/src/Broker/Mercure/MercureConfigFactory.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Factory; +namespace Maniaba\CodeIgniterSse\Broker\Mercure; -use Maniaba\CodeIgniterSse\Broker\Mercure\MercureConfig; use Maniaba\CodeIgniterSse\Config\Sse; final class MercureConfigFactory @@ -12,7 +11,7 @@ final class MercureConfigFactory 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'], @@ -63,4 +62,12 @@ private static function stringList(mixed $value): array 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 new file mode 100644 index 0000000..12dd40d --- /dev/null +++ b/src/Broker/Mercure/MercureSubscriptionEndpoint.php @@ -0,0 +1,103 @@ +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, + array $channels, + ): ResponseInterface { + $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) + ->setJSON([ + 'hub' => $subscription->hubUrl, + 'topics' => $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) { + $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/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/Null/NullBrokerAdapterFactory.php b/src/Broker/Null/NullBrokerAdapterFactory.php new file mode 100644 index 0000000..4844bdc --- /dev/null +++ b/src/Broker/Null/NullBrokerAdapterFactory.php @@ -0,0 +1,19 @@ +create($config, $context); + } +} 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/RedisBrokerAdapter.php b/src/Broker/Redis/RedisBrokerAdapter.php new file mode 100644 index 0000000..4a2961c --- /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..ef2046f --- /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->events, + SseConnectionOptions::fromConfig($config), + ); + + return new RedisBrokerAdapter( + $redis, + $publisher, + $subscriber, + new LocalSseSubscriptionEndpoint( + $manager, + $config->requireAcceptHeader, + channelSelectorValidator: new RedisChannelSelectorValidator($redis), + ), + new RedisHealthChecker($connectionFactory), + ); + } +} 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/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/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..420aa52 100644 --- a/src/Factory/RedisConfigFactory.php +++ b/src/Broker/Redis/RedisConfigFactory.php @@ -2,9 +2,8 @@ declare(strict_types=1); -namespace Maniaba\CodeIgniterSse\Factory; +namespace Maniaba\CodeIgniterSse\Broker\Redis; -use Maniaba\CodeIgniterSse\Broker\Redis\RedisConfig; use Maniaba\CodeIgniterSse\Config\Sse; final class RedisConfigFactory @@ -29,16 +28,24 @@ 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; + 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 23f7d80..107526c 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 { @@ -47,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; @@ -114,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 { @@ -126,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 * @@ -146,7 +197,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/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/Commands/HealthCheckCommand.php b/src/Commands/HealthCheckCommand.php index be4394e..8c61003 100644 --- a/src/Commands/HealthCheckCommand.php +++ b/src/Commands/HealthCheckCommand.php @@ -7,8 +7,9 @@ use CodeIgniter\CLI\BaseCommand; use CodeIgniter\CLI\CLI; use Maniaba\CodeIgniterSse\Config\Sse; -use Maniaba\CodeIgniterSse\Factory\HealthCheckerFactory; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Contracts\HealthCheckableInterface; +use Maniaba\CodeIgniterSse\Health\HealthCheckResult; final class HealthCheckCommand extends BaseCommand { @@ -22,56 +23,37 @@ final class HealthCheckCommand extends BaseCommand */ public function run(array $params): int { - $config = Sse::discover(); + $config = Sse::discover(); + $adapter = service('sseBrokerAdapter', $config, false); - if ($config->streamTransport() === 'mercure') { - $mercure = (new MercureConfigFactory())->create($config); + if (! $adapter instanceof BrokerAdapterInterface) { + CLI::error('The sseBrokerAdapter service must implement ' . BrokerAdapterInterface::class . '.'); - if (! function_exists('curl_version')) { - CLI::error('The Mercure publisher requires the PHP cURL extension.'); - - return EXIT_ERROR; - } + return EXIT_ERROR; + } + 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 +63,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/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/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/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..de7df4e 100644 --- a/src/Config/Sse.php +++ b/src/Config/Sse.php @@ -9,16 +9,14 @@ 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\InMemory\InMemoryBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Mercure\MercureBrokerAdapterFactory; +use Maniaba\CodeIgniterSse\Broker\Null\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; use Maniaba\CodeIgniterSse\Contracts\UserResolverInterface; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; use Maniaba\CodeIgniterSse\HTTP\SseController; class Sse extends BaseConfig @@ -43,6 +41,7 @@ class Sse extends BaseConfig 'maxPayloadBytes' => 1_048_576, 'maxResponseElements' => 1024, 'maxResponseDepth' => 8, + 'allowPatternSubscriptions' => false, 'clientName' => null, 'streamContext' => [], ]; @@ -119,41 +118,35 @@ class Sse extends BaseConfig /** * @var array, - * subscriber?: callable(self): SubscriberInterface|class-string, - * transport?: 'mercure'|'php', + * factory?: BrokerAdapterFactoryInterface|callable(): BrokerAdapterFactoryInterface|class-string, + * adapter?: BrokerAdapterInterface|callable(self, mixed): BrokerAdapterInterface|class-string, * shared?: bool * }> */ public array $brokers = [ 'redis' => [ - 'publisher' => RedisPublisher::class, - 'subscriber' => RedisSubscriber::class, + 'factory' => RedisBrokerAdapterFactory::class, ], 'mercure' => [ - 'publisher' => MercurePublisher::class, - 'transport' => 'mercure', + 'factory' => MercureBrokerAdapterFactory::class, ], '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, ], ]; - 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/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/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/Endpoint/LocalSseSubscriptionEndpoint.php b/src/Endpoint/LocalSseSubscriptionEndpoint.php new file mode 100644 index 0000000..3c4d639 --- /dev/null +++ b/src/Endpoint/LocalSseSubscriptionEndpoint.php @@ -0,0 +1,89 @@ +channelSelectorValidator ?? new ChannelNameValidator(); + } + + public function preflight(RequestInterface $request, ResponseInterface $response): ?ResponseInterface + { + if ( + ! $this->requireAcceptHeader + || (new AcceptHeaderNegotiator())->preferred( + strtolower($request->getHeaderLine('Accept')), + ['stream' => 'text/event-stream'], + ) === 'stream' + ) { + 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->appendHeader('Vary', 'Accept'); + $response->setHeader('X-Content-Type-Options', 'nosniff'); + + return $response; + } + + private function error( + ResponseInterface $response, + int $status, + string $code, + string $message, + ): ResponseInterface { + return $response + ->setStatusCode($status) + ->setJSON([ + 'error' => [ + 'code' => $code, + 'message' => $message, + ], + ]) + ->setHeader('Cache-Control', 'private, no-store') + ->appendHeader('Vary', 'Accept') + ->setHeader('X-Content-Type-Options', 'nosniff'); + } +} diff --git a/src/Factory/BrokerAdapterResolver.php b/src/Factory/BrokerAdapterResolver.php new file mode 100644 index 0000000..92bb768 --- /dev/null +++ b/src/Factory/BrokerAdapterResolver.php @@ -0,0 +1,154 @@ + + */ + private array $shared = []; + + public function __construct( + private readonly ?SerializerInterface $serializer = null, + private readonly ?EventFactory $events = 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) && 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']); + } + + if (array_key_exists('factory', $definition)) { + return $this->makeFactory($definition['factory']) + ->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 + { + if ($definition instanceof BrokerAdapterInterface) { + return $definition; + } + + 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; + } + } + + 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 (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 ?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, + ); + } - 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/ConnectionManagerFactory.php b/src/Factory/ConnectionManagerFactory.php deleted file mode 100644 index e919594..0000000 --- a/src/Factory/ConnectionManagerFactory.php +++ /dev/null @@ -1,28 +0,0 @@ -subscriber($config), - $serializer, - new EventFactory(), - $config->heartbeatInterval, - $config->maxConnectionSeconds, - $config->retryMilliseconds, - $config->emitConnectedEvent, - ); - } -} 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/MercureSubscriptionFactory.php b/src/Factory/MercureSubscriptionFactory.php index 29e75be..d9c4aad 100644 --- a/src/Factory/MercureSubscriptionFactory.php +++ b/src/Factory/MercureSubscriptionFactory.php @@ -4,6 +4,8 @@ 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; use Maniaba\CodeIgniterSse\Broker\Mercure\MercureTopicMapper; @@ -14,6 +16,7 @@ public function __construct( private ?MercureConfigFactory $configs = null, private ?MercureJwtFactory $tokens = null, + private ?MercureConfig $mercure = null, ) { } @@ -25,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/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/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 45f9c69..62f6611 100644 --- a/src/HTTP/SseController.php +++ b/src/HTTP/SseController.php @@ -4,35 +4,28 @@ namespace Maniaba\CodeIgniterSse\HTTP; +use CodeIgniter\API\ResponseTrait; +use CodeIgniter\Controller; use CodeIgniter\HTTP\ResponseInterface; -use CodeIgniter\RESTful\ResourceController; +use LogicException; use Maniaba\CodeIgniterSse\Config\Sse as SseConfig; -use Maniaba\CodeIgniterSse\Contracts\SseOutputInterface; +use Maniaba\CodeIgniterSse\Contracts\BrokerAdapterInterface; +use Maniaba\CodeIgniterSse\Contracts\ChannelSelectorValidatorProviderInterface; +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\ConnectionManagerFactory; -use Maniaba\CodeIgniterSse\Factory\MercureConfigFactory; -use Maniaba\CodeIgniterSse\Factory\MercureSubscriptionFactory; -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 ?MercureSubscriptionFactory $mercureSubscriptions = null, - private readonly ?SseConfig $config = 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); @@ -42,23 +35,18 @@ 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 { - $channels = $this->authorizeChannels($config); + $channels = $this->authorizeChannels($config, $endpoint); } catch (InvalidChannelException|InvalidChannelRequestException $exception) { return $cors->apply( $this->error(400, 'invalid_channels', $exception->getMessage()), @@ -71,101 +59,41 @@ 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; } /** * @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(); + $authorizations = new AuthorizationFactory(); $userResolver = $authorizations->userResolver($config); $authorization = $authorizations->channelAuthorization($config); return $authorization->authorizeAll($userResolver->resolve(), $channels); } - private function acceptsEventStream(): bool + private function subscriptionEndpoint(SseConfig $config): SubscriptionEndpointInterface { - $accept = strtolower($this->request->getHeaderLine('Accept')); - - if ($accept === '') { - return false; - } - - foreach (explode(',', $accept) as $mediaRange) { - $mediaType = trim(explode(';', $mediaRange, 2)[0]); + $adapter = service('sseBrokerAdapter', $config); - if ($mediaType === 'text/event-stream' || $mediaType === '*/*') { - return true; - } + if (! $adapter instanceof BrokerAdapterInterface) { + throw new LogicException('The sseBrokerAdapter service must implement ' . BrokerAdapterInterface::class . '.'); } - return false; + return $adapter->subscriptionEndpoint(); } private function error(int $status, string $code, string $message): ResponseInterface diff --git a/src/Health/HealthCheckResult.php b/src/Health/HealthCheckResult.php new file mode 100644 index 0000000..2559dea --- /dev/null +++ b/src/Health/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/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->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]), ); $state->stopWhen(! $output->event( - $this->serializer->serialize($connected->channel(), $connected->event()), + $this->encoder->encode($connected), $connected->event()->name(), $connected->id(), )); @@ -71,25 +56,19 @@ 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(), )); }, 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 +77,7 @@ public function stream(SseOutputInterface $output, array $channels): void $now = microtime(true); - if ($now - $lastHeartbeat < $this->heartbeatInterval) { + if ($now - $lastHeartbeat < $this->options->heartbeatInterval) { return; } @@ -122,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/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 @@ +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/InMemoryBrokerTest.php b/tests/Broker/InMemoryBrokerTest.php index 80cf562..0a94514 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; @@ -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/LocalBrokerAdapterTest.php b/tests/Broker/LocalBrokerAdapterTest.php new file mode 100644 index 0000000..8cff068 --- /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..acf88d9 --- /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/Mercure/MercureConfigTest.php b/tests/Broker/Mercure/MercureConfigTest.php new file mode 100644 index 0000000..cc1e530 --- /dev/null +++ b/tests/Broker/Mercure/MercureConfigTest.php @@ -0,0 +1,130 @@ +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/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 new file mode 100644 index 0000000..99ce627 --- /dev/null +++ b/tests/Broker/Redis/RedisBrokerAdapterTest.php @@ -0,0 +1,128 @@ +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', + 'allowPatternSubscriptions' => true, + ]; + $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()); + $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..7cdc698 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,83 @@ 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(); + $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/Broker/Redis/RedisHealthCheckerTest.php b/tests/Broker/Redis/RedisHealthCheckerTest.php index ac5ce36..59c9b18 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 @@ -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/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..4483a0f 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 @@ -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/Browser/SseClient.test.mjs b/tests/Browser/SseClient.test.mjs index 79864ba..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,7 +39,12 @@ class FakeEventSource { } } -test('builds the URL, dispatches envelopes, reports status, and closes', () => { +const nextTurn = () => new Promise((resolve) => setImmediate(resolve)); +const wait = (milliseconds) => new Promise((resolve) => { + setTimeout(resolve, milliseconds); +}); + +test('uses the direct adapter by default', () => { const source = new FakeEventSource(); const statuses = []; const named = []; @@ -61,6 +70,8 @@ test('builds the URL, dispatches envelopes, reports status, and closes', () => { .onMessage((message) => globalMessages.push(message)) .connect(); + 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'); assert.equal(url.searchParams.get('tenant'), '7'); @@ -104,6 +115,20 @@ test('builds the URL, dispatches envelopes, reports status, and closes', () => { ]); }); +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 = []; @@ -141,6 +166,7 @@ test('subscribes and unsubscribes channels by reconnecting active sources', () = const client = new SseClient({ endpoint: 'https://example.test/sse', channels: ['public.news'], + adapter: new RedisSseAdapter(), eventSourceFactory: (url) => { const source = new FakeEventSource(); @@ -190,7 +216,7 @@ test('subscribes and unsubscribes channels by reconnecting active sources', () = ]); }); -test('authorizes Mercure channels and opens 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; @@ -198,25 +224,25 @@ test('authorizes Mercure channels and opens EventSource directly on the Hub', as const client = new SseClient({ endpoint: 'https://app.example.test/sse', - transport: 'mercure', + 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, + }), + }; + }, + }), channels: ['users.42', 'projects.7'], - fetchFactory: async (url, options) => { - fetchCalls.push({ url, options }); - - return { - 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', - ], - expiresAt: null, - }), - }; - }, eventSourceFactory: (url, options) => { receivedHubUrl = url; receivedOptions = options; @@ -227,7 +253,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,17 +281,73 @@ test('authorizes Mercure channels and opens EventSource directly on the Hub', as client.close(); }); -test('reports Mercure authorization failures without opening EventSource', async () => { +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://example.test/sse', + channels: ['public.news'], + adapter, + eventSourceFactory: (url) => { + sourceUrls.push(url); + + return new FakeEventSource(); + }, + }); + + client.connect(); + client.subscribe('users.42'); + await nextTurn(); + + 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('reports adapter 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, - status: 403, - json: async () => ({}), + adapter: new MercureSseAdapter({ + fetchFactory: async () => ({ + ok: false, + status: 403, + json: async () => ({}), + }), }), eventSourceFactory: () => { eventSourceCalls++; @@ -276,27 +358,69 @@ 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, ['authorization-error']); + assert.deepEqual(fallbackReasons, ['adapter-error']); }); -test('keeps Mercure EventSource construction errors distinct from authorization', 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', - 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'], - 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: () => { @@ -306,7 +430,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/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/Config/ServicesTest.php b/tests/Config/ServicesTest.php index e3abd23..730f266 100644 --- a/tests/Config/ServicesTest.php +++ b/tests/Config/ServicesTest.php @@ -5,26 +5,35 @@ 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\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; 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; -use Tests\Config\Fixtures\ConfiguredChannelAuthorizer; +use Support\Tests\Adapter\BasicBrokerAdapter; +use Support\Tests\Adapter\BasicSubscriptionEndpoint; +use Support\Tests\Config\Fixtures\ConfiguredChannelAuthorizer; /** * @internal @@ -71,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); @@ -129,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 @@ -147,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(); @@ -160,22 +170,88 @@ public function subscribe( $this->assertSame($subscriber, $brokers->subscriber($config)); } - public function testCustomBrokerCanUseSimpleClassNames(): void + 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 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), ); } @@ -203,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); @@ -241,6 +318,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 { 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/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 7bf9db4..ae1fd41 100644 --- a/tests/Debug/Toolbar/SseEventsCollectorTest.php +++ b/tests/Debug/Toolbar/SseEventsCollectorTest.php @@ -4,12 +4,12 @@ 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; -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 new file mode 100644 index 0000000..f6afce1 --- /dev/null +++ b/tests/Factory/BrokerAdapterResolverTest.php @@ -0,0 +1,277 @@ +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 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(); + $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 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([ + '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 testRejectsBrokerDefinitionsWithoutFactoryOrAdapter(): void + { + $this->expectException(LogicException::class); + $this->expectExceptionMessage('must define either "factory" or "adapter"'); + + (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 + */ + private function config(array $definition): Sse + { + $config = new Sse(); + $config->broker = 'custom'; + $config->brokers['custom'] = $definition; + + return $config; + } +} 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/SseControllerTest.php b/tests/HTTP/SseControllerTest.php index 387b8b4..5dcd0ad 100644 --- a/tests/HTTP/SseControllerTest.php +++ b/tests/HTTP/SseControllerTest.php @@ -9,24 +9,28 @@ 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 Tests\Support\FixedEventIdGenerator; -use Tests\Support\RecordingSubscriber; +use Support\Tests\Adapter\BasicBrokerAdapter; +use Support\Tests\FixedEventIdGenerator; +use Support\Tests\RecordingSubscriber; /** * @internal */ 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()); @@ -34,6 +38,37 @@ public function testRequiresEventStreamAcceptHeader(): void $this->assertStringContainsString('not_acceptable', $body); } + public function testLocalRouteRejectsJsonAcceptHeader(): 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(406, $result->getStatusCode()); + $this->assertStringStartsWith('application/json', $result->getHeaderLine('Content-Type')); + $this->assertSame('Accept', $result->getHeaderLine('Vary')); + $this->assertIsString($body); + $this->assertStringContainsString('not_acceptable', $body); + } finally { + $superglobals->setGetArray($previousGet); + } + } + public function testRejectsUnknownOriginBeforeContentNegotiation(): void { $result = $this->controllerResponse('https://attacker.example.com', null); @@ -48,7 +83,6 @@ public function testAuthorizedPublicChannelProducesAStreamingResponse(): void { $manager = new SseConnectionManager( new RecordingSubscriber(), - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator('connected-id')), ); $factoryResponse = single_service('response'); @@ -63,8 +97,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 +157,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(); @@ -134,9 +177,15 @@ 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'], + 'https://example.test/.well-known/mercure', + $decoded['hub'], + ); + $this->assertSame( + [ + 'urn:example:sse:public.news', + 'urn:example:sse:public.status', + ], $decoded['topics'], ); $this->assertIsInt($decoded['expiresAt']); @@ -148,14 +197,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 +225,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'); + } + } } } diff --git a/tests/HTTP/SubscriptionEndpointTest.php b/tests/HTTP/SubscriptionEndpointTest.php new file mode 100644 index 0000000..f6e4560 --- /dev/null +++ b/tests/HTTP/SubscriptionEndpointTest.php @@ -0,0 +1,320 @@ +http(); + $endpoint = new LocalSseSubscriptionEndpoint($this->manager()); + + $result = $endpoint->preflight($request, $response); + + $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()); + } + + #[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 'text wildcard' => ['text/*;q=0.5', true]; + + yield 'wildcard' => ['*/*', 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 'JSON only' => ['application/json']; + + yield 'event stream q zero' => ['text/event-stream;q=0']; + + yield 'wildcard q zero' => ['*/*;q=0']; + + yield 'event stream excluded despite wildcard' => [ + 'text/event-stream;q=0, */*;q=1', + ]; + + yield 'text excluded despite wildcard' => [ + 'text/*;q=0, */*;q=1', + ]; + + yield 'malformed quality values' => [ + 'text/event-stream;q=2', + ]; + } + + 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('Accept', $result->getHeaderLine('Vary')); + $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 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(); + $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->assertSame('Accept', $result->getHeaderLine('Vary')); + $this->assertIsString($body); + + $decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR); + $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()); + } + + #[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(); + $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()); + } + + public function testMercureEndpointUsesPlainChannelNameSelectors(): void + { + $endpoint = new MercureSubscriptionEndpoint($this->mercureConfig()); + + $this->assertInstanceOf(ChannelNameValidator::class, $endpoint->channelSelectorValidator()); + } + + /** + * @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 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/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/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/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 130d1df..7af765c 100644 --- a/tests/Stream/SseConnectionManagerTest.php +++ b/tests/Stream/SseConnectionManagerTest.php @@ -8,13 +8,13 @@ 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; 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 @@ -36,7 +36,6 @@ public function testStreamsConnectedAndBrokerEventsWithIds(): void $output = new RecordingSseOutput(); $manager = new SseConnectionManager( $subscriber, - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator('connected-event')), ); @@ -94,9 +93,8 @@ public function isClientConnected(): bool }; $manager = new SseConnectionManager( $subscriber, - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator()), - emitConnectedEvent: false, + new SseConnectionOptions(emitConnectedEvent: false), ); $manager->stream($output, ['public.news']); @@ -111,7 +109,6 @@ public function testFailedRetryWriteDoesNotStartTheSubscriber(): void $output->connected = false; $manager = new SseConnectionManager( $subscriber, - new JsonEventSerializer(), new EventFactory(new FixedEventIdGenerator()), ); 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.*'); + } +} diff --git a/tests/_support/Adapter/BasicBrokerAdapter.php b/tests/_support/Adapter/BasicBrokerAdapter.php new file mode 100644 index 0000000..386a880 --- /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..dddefc4 --- /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..de9c823 --- /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(); + } + } +} 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;