From da3abf1327257412cc2e2bbe507a688d89e5d18a Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Thu, 12 Oct 2023 21:56:39 +0100 Subject: [PATCH 1/9] Troubleshooting: Add some additional logging (#35) --- index.php | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/index.php b/index.php index 81a120a..3303339 100644 --- a/index.php +++ b/index.php @@ -207,9 +207,12 @@ function exception_error_handler($severity, $message, $file, $line) { return ($a->qos === $b->qos) ? 0 : (($a->qos < $b->qos) ? -1 : 1); }); + $log->debug('Queue Poll - message queue sorted'); + // Send up to X messages. for ($i = 0; $i < $queueSize; $i++) { if ($i > count($messageQueue)) { + $log->debug('Queue Poll - queue size reached'); break; } @@ -217,6 +220,8 @@ function exception_error_handler($severity, $message, $file, $line) { $msg = array_pop($messageQueue); // Send + $log->debug('Sending ' . $i); + $messageStats['messageCounters']['sent']++; $publisher->sendmulti([$msg->channel, $msg->key, $msg->message], \ZMQ::MODE_DONTWAIT); From 8e66e5e2c1e02ab9781679959d8ac993e9a01754 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Tue, 10 Dec 2024 14:28:06 +0000 Subject: [PATCH 2/9] Web Sockets (#45) --- Dockerfile | 17 +- composer.json | 14 +- composer.lock | 1770 +++++++++++++++++++++++++++++++++++-- docker-compose.yml | 3 + entrypoint.sh | 8 +- index.php | 251 +++--- src/Controller/Api.php | 66 ++ src/Controller/Server.php | 180 ++++ src/Entity/Display.php | 35 + src/Entity/Message.php | 31 + src/Entity/Queue.php | 200 +++++ tests/Private API.http | 19 + tests/cmsGetStats.php | 85 -- tests/cmsSend.php | 53 +- tests/playerReq.php | 19 - tests/playerSub.php | 14 +- 16 files changed, 2389 insertions(+), 376 deletions(-) create mode 100644 src/Controller/Api.php create mode 100644 src/Controller/Server.php create mode 100644 src/Entity/Display.php create mode 100644 src/Entity/Message.php create mode 100644 src/Entity/Queue.php create mode 100644 tests/Private API.http delete mode 100644 tests/cmsGetStats.php delete mode 100644 tests/playerReq.php diff --git a/Dockerfile b/Dockerfile index 4b51c36..faff564 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,15 +1,14 @@ -FROM composer:1.6 as composer +FROM composer AS composer COPY . /app RUN composer install --no-interaction --no-dev --ignore-platform-reqs --optimize-autoloader -FROM php:8.1-cli -MAINTAINER Xibo Signage Ltd +FROM php:8.2-cli +LABEL org.opencontainers.image.authors="Xibo Signage Ltd " -ENV XMR_DEBUG false -ENV XMR_QUEUE_POLL 5 -ENV XMR_QUEUE_SIZE 10 -ENV XMR_IPV6RESPSUPPORT false -ENV XMR_IPV6PUBSUPPORT false +ENV XMR_DEBUG=false +ENV XMR_QUEUE_POLL=5 +ENV XMR_QUEUE_SIZE=10 +ENV XMR_IPV6PUBSUPPORT=false RUN apt-get update && apt-get install -y libzmq3-dev git \ && rm -rf /var/lib/apt/lists/* @@ -24,7 +23,7 @@ RUN git clone https://github.com/zeromq/php-zmq.git \ RUN docker-php-ext-enable zmq -EXPOSE 9505 50001 +EXPOSE 8080 8081 9505 COPY ./entrypoint.sh /entrypoint.sh COPY . /opt/xmr diff --git a/composer.json b/composer.json index 347b9b9..739f8a4 100644 --- a/composer.json +++ b/composer.json @@ -21,13 +21,21 @@ "bin": ["bin/xmr.phar"], "config": { "platform": { - "php": "8.1", + "php": "8.2", "ext-zmq": "1" } }, "require": { - "php": ">=8.1", + "php": ">=8.2", "monolog/monolog": "^1.17", - "react/zmq": "^0.4.0" + "react/react": "^1.4", + "react/socket": "^1.16", + "react/zmq": "^0.4.0", + "cboden/ratchet": "^0.4.4" + }, + "autoload": { + "psr-4": { + "Xibo\\": "src/" + } } } diff --git a/composer.lock b/composer.lock index c3fe608..226ad28 100644 --- a/composer.lock +++ b/composer.lock @@ -4,32 +4,95 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "be10c4aceb6ae6e6de027b6f5dfc5028", + "content-hash": "2962bb0981206b9c30166891cdec6983", "packages": [ + { + "name": "cboden/ratchet", + "version": "v0.4.4", + "source": { + "type": "git", + "url": "https://github.com/ratchetphp/Ratchet.git", + "reference": "5012dc954541b40c5599d286fd40653f5716a38f" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/ratchetphp/Ratchet/zipball/5012dc954541b40c5599d286fd40653f5716a38f", + "reference": "5012dc954541b40c5599d286fd40653f5716a38f", + "shasum": "" + }, + "require": { + "guzzlehttp/psr7": "^1.7|^2.0", + "php": ">=5.4.2", + "ratchet/rfc6455": "^0.3.1", + "react/event-loop": ">=0.4", + "react/socket": "^1.0 || ^0.8 || ^0.7 || ^0.6 || ^0.5", + "symfony/http-foundation": "^2.6|^3.0|^4.0|^5.0|^6.0", + "symfony/routing": "^2.6|^3.0|^4.0|^5.0|^6.0" + }, + "require-dev": { + "phpunit/phpunit": "~4.8" + }, + "type": "library", + "autoload": { + "psr-4": { + "Ratchet\\": "src/Ratchet" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "PHP WebSocket library", + "homepage": "http://socketo.me", + "keywords": [ + "Ratchet", + "WebSockets", + "server", + "sockets", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/Ratchet/issues", + "source": "https://github.com/ratchetphp/Ratchet/tree/v0.4.4" + }, + "time": "2021-12-14T00:20:41+00:00" + }, { "name": "evenement/evenement", - "version": "v3.0.1", + "version": "v3.0.2", "source": { "type": "git", "url": "https://github.com/igorw/evenement.git", - "reference": "531bfb9d15f8aa57454f5f0285b18bec903b8fb7" + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/igorw/evenement/zipball/531bfb9d15f8aa57454f5f0285b18bec903b8fb7", - "reference": "531bfb9d15f8aa57454f5f0285b18bec903b8fb7", + "url": "https://api.github.com/repos/igorw/evenement/zipball/0a16b0d71ab13284339abb99d9d2bd813640efbc", + "reference": "0a16b0d71ab13284339abb99d9d2bd813640efbc", "shasum": "" }, "require": { "php": ">=7.0" }, "require-dev": { - "phpunit/phpunit": "^6.0" + "phpunit/phpunit": "^9 || ^6" }, "type": "library", "autoload": { - "psr-0": { - "Evenement": "src" + "psr-4": { + "Evenement\\": "src/" } }, "notification-url": "https://packagist.org/downloads/", @@ -47,20 +110,196 @@ "event-dispatcher", "event-emitter" ], - "time": "2017-07-23T21:35:13+00:00" + "support": { + "issues": "https://github.com/igorw/evenement/issues", + "source": "https://github.com/igorw/evenement/tree/v3.0.2" + }, + "time": "2023-08-08T05:53:35+00:00" + }, + { + "name": "fig/http-message-util", + "version": "1.1.5", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message-util.git", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message-util/zipball/9d94dc0154230ac39e5bf89398b324a86f63f765", + "reference": "9d94dc0154230ac39e5bf89398b324a86f63f765", + "shasum": "" + }, + "require": { + "php": "^5.3 || ^7.0 || ^8.0" + }, + "suggest": { + "psr/http-message": "The package containing the PSR-7 interfaces" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Fig\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Utility classes and constants for use with PSR-7 (psr/http-message)", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "issues": "https://github.com/php-fig/http-message-util/issues", + "source": "https://github.com/php-fig/http-message-util/tree/1.1.5" + }, + "time": "2020-11-24T22:02:12+00:00" + }, + { + "name": "guzzlehttp/psr7", + "version": "2.7.0", + "source": { + "type": "git", + "url": "https://github.com/guzzle/psr7.git", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/psr7/zipball/a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "reference": "a70f5c95fb43bc83f07c9c948baa0dc1829bf201", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0", + "psr/http-factory": "^1.0", + "psr/http-message": "^1.1 || ^2.0", + "ralouphie/getallheaders": "^3.0" + }, + "provide": { + "psr/http-factory-implementation": "1.0", + "psr/http-message-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "http-interop/http-factory-tests": "0.9.0", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "suggest": { + "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Psr7\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://sagikazarmark.hu" + } + ], + "description": "PSR-7 message implementation that also provides common utility methods", + "keywords": [ + "http", + "message", + "psr-7", + "request", + "response", + "stream", + "uri", + "url" + ], + "support": { + "issues": "https://github.com/guzzle/psr7/issues", + "source": "https://github.com/guzzle/psr7/tree/2.7.0" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7", + "type": "tidelift" + } + ], + "time": "2024-07-18T11:15:46+00:00" }, { "name": "monolog/monolog", - "version": "1.27.0", + "version": "1.27.1", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "52ebd235c1f7e0d5e1b16464b695a28335f8e44a" + "reference": "904713c5929655dc9b97288b69cfeedad610c9a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/52ebd235c1f7e0d5e1b16464b695a28335f8e44a", - "reference": "52ebd235c1f7e0d5e1b16464b695a28335f8e44a", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/904713c5929655dc9b97288b69cfeedad610c9a1", + "reference": "904713c5929655dc9b97288b69cfeedad610c9a1", "shasum": "" }, "require": { @@ -119,6 +358,10 @@ "logging", "psr-3" ], + "support": { + "issues": "https://github.com/Seldaek/monolog/issues", + "source": "https://github.com/Seldaek/monolog/tree/1.27.1" + }, "funding": [ { "url": "https://github.com/Seldaek", @@ -129,7 +372,115 @@ "type": "tidelift" } ], - "time": "2022-03-13T20:29:46+00:00" + "time": "2022-06-09T08:53:42+00:00" + }, + { + "name": "psr/http-factory", + "version": "1.1.0", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-factory.git", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a", + "shasum": "" + }, + "require": { + "php": ">=7.1", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories", + "keywords": [ + "factory", + "http", + "message", + "psr", + "psr-17", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-factory" + }, + "time": "2024-04-15T12:06:14+00:00" + }, + { + "name": "psr/http-message", + "version": "1.1", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-message.git", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-message/zipball/cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "reference": "cb6ce4845ce34a8ad9e68117c10ee90a29919eba", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.1.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Message\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "http://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP messages", + "homepage": "https://github.com/php-fig/http-message", + "keywords": [ + "http", + "http-message", + "psr", + "psr-7", + "request", + "response" + ], + "support": { + "source": "https://github.com/php-fig/http-message/tree/1.1" + }, + "time": "2023-04-04T09:50:52+00:00" }, { "name": "psr/log", @@ -176,38 +527,37 @@ "psr", "psr-3" ], + "support": { + "source": "https://github.com/php-fig/log/tree/1.1.4" + }, "time": "2021-05-03T11:20:27+00:00" }, { - "name": "react/event-loop", - "version": "v1.2.0", + "name": "ralouphie/getallheaders", + "version": "3.0.3", "source": { "type": "git", - "url": "https://github.com/reactphp/event-loop.git", - "reference": "be6dee480fc4692cec0504e65eb486e3be1aa6f2" + "url": "https://github.com/ralouphie/getallheaders.git", + "reference": "120b605dfeb996808c31b6477290a714d356e822" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/reactphp/event-loop/zipball/be6dee480fc4692cec0504e65eb486e3be1aa6f2", - "reference": "be6dee480fc4692cec0504e65eb486e3be1aa6f2", + "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822", + "reference": "120b605dfeb996808c31b6477290a714d356e822", "shasum": "" }, "require": { - "php": ">=5.3.0" + "php": ">=5.6" }, "require-dev": { - "phpunit/phpunit": "^9.3 || ^5.7 || ^4.8.35" - }, - "suggest": { - "ext-event": "~1.0 for ExtEventLoop", - "ext-pcntl": "For signal handling support when using the StreamSelectLoop", - "ext-uv": "* for ExtUvLoop" + "php-coveralls/php-coveralls": "^2.1", + "phpunit/phpunit": "^5 || ^6.5" }, "type": "library", "autoload": { - "psr-4": { - "React\\EventLoop\\": "src" - } + "files": [ + "src/getallheaders.php" + ] }, "notification-url": "https://packagist.org/downloads/", "license": [ @@ -215,83 +565,1339 @@ ], "authors": [ { - "name": "Christian Lück", - "email": "christian@clue.engineering", - "homepage": "https://clue.engineering/" - }, - { - "name": "Cees-Jan Kiewiet", - "email": "reactphp@ceesjankiewiet.nl", - "homepage": "https://wyrihaximus.net/" - }, - { - "name": "Jan Sorgalla", - "email": "jsorgalla@gmail.com", - "homepage": "https://sorgalla.com/" - }, - { - "name": "Chris Boden", - "email": "cboden@gmail.com", - "homepage": "https://cboden.dev/" - } - ], - "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", - "keywords": [ - "asynchronous", - "event-loop" - ], - "funding": [ - { - "url": "https://github.com/WyriHaximus", - "type": "github" - }, - { - "url": "https://github.com/clue", - "type": "github" + "name": "Ralph Khattar", + "email": "ralph.khattar@gmail.com" } ], - "time": "2021-07-11T12:31:24+00:00" + "description": "A polyfill for getallheaders.", + "support": { + "issues": "https://github.com/ralouphie/getallheaders/issues", + "source": "https://github.com/ralouphie/getallheaders/tree/develop" + }, + "time": "2019-03-08T08:55:37+00:00" }, { - "name": "react/zmq", - "version": "v0.4.0", + "name": "ratchet/rfc6455", + "version": "v0.3.1", "source": { "type": "git", - "url": "https://github.com/friends-of-reactphp/zmq.git", - "reference": "13dec0bd2397adcc5d6aa54c8d7f0982fba66f39" + "url": "https://github.com/ratchetphp/RFC6455.git", + "reference": "7c964514e93456a52a99a20fcfa0de242a43ccdb" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/friends-of-reactphp/zmq/zipball/13dec0bd2397adcc5d6aa54c8d7f0982fba66f39", - "reference": "13dec0bd2397adcc5d6aa54c8d7f0982fba66f39", + "url": "https://api.github.com/repos/ratchetphp/RFC6455/zipball/7c964514e93456a52a99a20fcfa0de242a43ccdb", + "reference": "7c964514e93456a52a99a20fcfa0de242a43ccdb", "shasum": "" }, "require": { - "evenement/evenement": "^3.0 || ^2.0", - "ext-zmq": "*", - "php": ">=5.4.0", - "react/event-loop": "^1.0 || ^0.5 || ^0.4" + "guzzlehttp/psr7": "^2 || ^1.7", + "php": ">=5.4.2" }, "require-dev": { - "ext-pcntl": "*", - "phpunit/phpunit": "~4.8.35 || ~5.7 || ~6.4" + "phpunit/phpunit": "^5.7", + "react/socket": "^1.3" }, "type": "library", "autoload": { "psr-4": { - "React\\ZMQ\\": "src" + "Ratchet\\RFC6455\\": "src" } }, "notification-url": "https://packagist.org/downloads/", "license": [ "MIT" ], - "description": "ZeroMQ bindings for React.", - "keywords": [ + "authors": [ + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "role": "Developer" + }, + { + "name": "Matt Bonneau", + "role": "Developer" + } + ], + "description": "RFC6455 WebSocket protocol handler", + "homepage": "http://socketo.me", + "keywords": [ + "WebSockets", + "rfc6455", + "websocket" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/ratchetphp/RFC6455/issues", + "source": "https://github.com/ratchetphp/RFC6455/tree/v0.3.1" + }, + "time": "2021-12-09T23:20:49+00:00" + }, + { + "name": "react/async", + "version": "v4.3.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/async.git", + "reference": "635d50e30844a484495713e8cb8d9e079c0008a5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/async/zipball/635d50e30844a484495713e8cb8d9e079c0008a5", + "reference": "635d50e30844a484495713e8cb8d9e079c0008a5", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.8 || ^1.2.1" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39", + "phpunit/phpunit": "^9.6" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Async\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async utilities and fibers for ReactPHP", + "keywords": [ + "async", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/async/issues", + "source": "https://github.com/reactphp/async/tree/v4.3.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:40:02+00:00" + }, + { + "name": "react/cache", + "version": "v1.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/cache.git", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/cache/zipball/d47c472b64aa5608225f47965a484b75c7817d5b", + "reference": "d47c472b64aa5608225f47965a484b75c7817d5b", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/promise": "^3.0 || ^2.0 || ^1.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.5 || ^5.7 || ^4.8.35" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Cache\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, Promise-based cache interface for ReactPHP", + "keywords": [ + "cache", + "caching", + "promise", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/cache/issues", + "source": "https://github.com/reactphp/cache/tree/v1.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2022-11-30T15:59:55+00:00" + }, + { + "name": "react/dns", + "version": "v1.13.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/dns.git", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/dns/zipball/eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "reference": "eb8ae001b5a455665c89c1df97f6fb682f8fb0f5", + "shasum": "" + }, + "require": { + "php": ">=5.3.0", + "react/cache": "^1.0 || ^0.6 || ^0.5", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3 || ^2", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Dns\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async DNS resolver for ReactPHP", + "keywords": [ + "async", + "dns", + "dns-resolver", + "reactphp" + ], + "support": { + "issues": "https://github.com/reactphp/dns/issues", + "source": "https://github.com/reactphp/dns/tree/v1.13.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-13T14:18:03+00:00" + }, + { + "name": "react/event-loop", + "version": "v1.5.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/event-loop.git", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/event-loop/zipball/bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "reference": "bbe0bd8c51ffc05ee43f1729087ed3bdf7d53354", + "shasum": "" + }, + "require": { + "php": ">=5.3.0" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "suggest": { + "ext-pcntl": "For signal handling support when using the StreamSelectLoop" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\EventLoop\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "ReactPHP's core reactor event loop that libraries can use for evented I/O.", + "keywords": [ + "asynchronous", + "event-loop" + ], + "support": { + "issues": "https://github.com/reactphp/event-loop/issues", + "source": "https://github.com/reactphp/event-loop/tree/v1.5.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-11-13T13:48:05+00:00" + }, + { + "name": "react/http", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/http.git", + "reference": "8db02de41dcca82037367f67a2d4be365b1c4db9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/http/zipball/8db02de41dcca82037367f67a2d4be365b1c4db9", + "reference": "8db02de41dcca82037367f67a2d4be365b1c4db9", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "fig/http-message-util": "^1.1", + "php": ">=5.3.0", + "psr/http-message": "^1.0", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.3 || ^1.2.1", + "react/socket": "^1.16", + "react/stream": "^1.4" + }, + "require-dev": { + "clue/http-proxy-react": "^1.8", + "clue/reactphp-ssh-proxy": "^1.4", + "clue/socks-react": "^1.4", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.2 || ^3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Http\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven, streaming HTTP client and server implementation for ReactPHP", + "keywords": [ + "async", + "client", + "event-driven", + "http", + "http client", + "http server", + "https", + "psr-7", + "reactphp", + "server", + "streaming" + ], + "support": { + "issues": "https://github.com/reactphp/http/issues", + "source": "https://github.com/reactphp/http/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-11-20T15:24:08+00:00" + }, + { + "name": "react/promise", + "version": "v3.2.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise.git", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise/zipball/8a164643313c71354582dc850b42b33fa12a4b63", + "reference": "8a164643313c71354582dc850b42b33fa12a4b63", + "shasum": "" + }, + "require": { + "php": ">=7.1.0" + }, + "require-dev": { + "phpstan/phpstan": "1.10.39 || 1.4.10", + "phpunit/phpunit": "^9.6 || ^7.5" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A lightweight implementation of CommonJS Promises/A for PHP", + "keywords": [ + "promise", + "promises" + ], + "support": { + "issues": "https://github.com/reactphp/promise/issues", + "source": "https://github.com/reactphp/promise/tree/v3.2.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-05-24T10:39:05+00:00" + }, + { + "name": "react/promise-stream", + "version": "v1.7.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-stream.git", + "reference": "5c7ec3450f558deb779742e33967d837e2db7871" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-stream/zipball/5c7ec3450f558deb779742e33967d837e2db7871", + "reference": "5c7ec3450f558deb779742e33967d837e2db7871", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/promise": "^3 || ^2.1 || ^1.2", + "react/stream": "^1.0 || ^0.7 || ^0.6 || ^0.5 || ^0.4.6" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "The missing link between Promise-land and Stream-land for ReactPHP", + "homepage": "https://github.com/reactphp/promise-stream", + "keywords": [ + "Buffer", + "async", + "promise", + "reactphp", + "stream", + "unwrap" + ], + "support": { + "issues": "https://github.com/reactphp/promise-stream/issues", + "source": "https://github.com/reactphp/promise-stream/tree/v1.7.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-12-13T11:32:02+00:00" + }, + { + "name": "react/promise-timer", + "version": "v1.11.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/promise-timer.git", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/promise-timer/zipball/4f70306ed66b8b44768941ca7f142092600fafc1", + "reference": "4f70306ed66b8b44768941ca7f142092600fafc1", + "shasum": "" + }, + "require": { + "php": ">=5.3", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.7.0 || ^1.2.1" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "React\\Promise\\Timer\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "A trivial implementation of timeouts for Promises, built on top of ReactPHP.", + "homepage": "https://github.com/reactphp/promise-timer", + "keywords": [ + "async", + "event-loop", + "promise", + "reactphp", + "timeout", + "timer" + ], + "support": { + "issues": "https://github.com/reactphp/promise-timer/issues", + "source": "https://github.com/reactphp/promise-timer/tree/v1.11.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-04T14:27:45+00:00" + }, + { + "name": "react/react", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/reactphp.git", + "reference": "726e5de40567c9effaa8e5665b1a2621af8d7ee9" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/reactphp/zipball/726e5de40567c9effaa8e5665b1a2621af8d7ee9", + "reference": "726e5de40567c9effaa8e5665b1a2621af8d7ee9", + "shasum": "" + }, + "require": { + "php": ">=5.3.8", + "react/async": "^4 || ^3 || ^2", + "react/cache": "^1.1", + "react/dns": "^1.11", + "react/event-loop": "^1.4", + "react/http": "^1.8", + "react/promise": "^3 || ^2.10 || ^1.2", + "react/promise-stream": "^1.6", + "react/promise-timer": "^1.9", + "react/socket": "^1.13", + "react/stream": "^1.3" + }, + "require-dev": { + "clue/stream-filter": "^1.3", + "phpunit/phpunit": "^9.6 || ^7.5 || ^5.7 || ^4.8.36", + "react/async": "^4.2@dev || ^3.2@dev || ^4 || ^3 || ^2", + "react/dns": "^1.12@dev", + "react/http": "^1.10@dev", + "react/promise": "^3@dev || ^2.10 || ^1.2", + "react/promise-stream": "^1.7@dev", + "react/promise-timer": "^1.10@dev", + "react/socket": "^1.14@dev" + }, + "type": "library", + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "ReactPHP: Event-driven, non-blocking I/O with PHP.", + "homepage": "https://reactphp.org/", + "keywords": [ + "asynchronous", + "reactor", + "reactphp" + ], + "support": { + "chat": "https://gitter.im/reactphp/reactphp", + "issues": "https://github.com/reactphp/reactphp/issues", + "source": "https://github.com/reactphp/reactphp/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2023-07-11T16:08:54+00:00" + }, + { + "name": "react/socket", + "version": "v1.16.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/socket.git", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/socket/zipball/23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "reference": "23e4ff33ea3e160d2d1f59a0e6050e4b0fb0eac1", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.0", + "react/dns": "^1.13", + "react/event-loop": "^1.2", + "react/promise": "^3.2 || ^2.6 || ^1.2.1", + "react/stream": "^1.4" + }, + "require-dev": { + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36", + "react/async": "^4.3 || ^3.3 || ^2", + "react/promise-stream": "^1.4", + "react/promise-timer": "^1.11" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Socket\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Async, streaming plaintext TCP/IP and secure TLS socket server and client connections for ReactPHP", + "keywords": [ + "Connection", + "Socket", + "async", + "reactphp", + "stream" + ], + "support": { + "issues": "https://github.com/reactphp/socket/issues", + "source": "https://github.com/reactphp/socket/tree/v1.16.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-07-26T10:38:09+00:00" + }, + { + "name": "react/stream", + "version": "v1.4.0", + "source": { + "type": "git", + "url": "https://github.com/reactphp/stream.git", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/reactphp/stream/zipball/1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "reference": "1e5b0acb8fe55143b5b426817155190eb6f5b18d", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0 || ^1.0", + "php": ">=5.3.8", + "react/event-loop": "^1.2" + }, + "require-dev": { + "clue/stream-filter": "~1.2", + "phpunit/phpunit": "^9.6 || ^5.7 || ^4.8.36" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\Stream\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Christian Lück", + "email": "christian@clue.engineering", + "homepage": "https://clue.engineering/" + }, + { + "name": "Cees-Jan Kiewiet", + "email": "reactphp@ceesjankiewiet.nl", + "homepage": "https://wyrihaximus.net/" + }, + { + "name": "Jan Sorgalla", + "email": "jsorgalla@gmail.com", + "homepage": "https://sorgalla.com/" + }, + { + "name": "Chris Boden", + "email": "cboden@gmail.com", + "homepage": "https://cboden.dev/" + } + ], + "description": "Event-driven readable and writable streams for non-blocking I/O in ReactPHP", + "keywords": [ + "event-driven", + "io", + "non-blocking", + "pipe", + "reactphp", + "readable", + "stream", + "writable" + ], + "support": { + "issues": "https://github.com/reactphp/stream/issues", + "source": "https://github.com/reactphp/stream/tree/v1.4.0" + }, + "funding": [ + { + "url": "https://opencollective.com/reactphp", + "type": "open_collective" + } + ], + "time": "2024-06-11T12:45:25+00:00" + }, + { + "name": "react/zmq", + "version": "v0.4.0", + "source": { + "type": "git", + "url": "https://github.com/friends-of-reactphp/zmq.git", + "reference": "13dec0bd2397adcc5d6aa54c8d7f0982fba66f39" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/friends-of-reactphp/zmq/zipball/13dec0bd2397adcc5d6aa54c8d7f0982fba66f39", + "reference": "13dec0bd2397adcc5d6aa54c8d7f0982fba66f39", + "shasum": "" + }, + "require": { + "evenement/evenement": "^3.0 || ^2.0", + "ext-zmq": "*", + "php": ">=5.4.0", + "react/event-loop": "^1.0 || ^0.5 || ^0.4" + }, + "require-dev": { + "ext-pcntl": "*", + "phpunit/phpunit": "~4.8.35 || ~5.7 || ~6.4" + }, + "type": "library", + "autoload": { + "psr-4": { + "React\\ZMQ\\": "src" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "description": "ZeroMQ bindings for React.", + "keywords": [ "zeromq", "zmq" ], + "support": { + "issues": "https://github.com/friends-of-reactphp/zmq/issues", + "source": "https://github.com/friends-of-reactphp/zmq/tree/master" + }, "time": "2018-05-18T15:27:55+00:00" + }, + { + "name": "symfony/deprecation-contracts", + "version": "v3.5.1", + "source": { + "type": "git", + "url": "https://github.com/symfony/deprecation-contracts.git", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "reference": "74c71c939a79f7d5bf3c1ce9f5ea37ba0114c6f6", + "shasum": "" + }, + "require": { + "php": ">=8.1" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-main": "3.5-dev" + }, + "thanks": { + "name": "symfony/contracts", + "url": "https://github.com/symfony/contracts" + } + }, + "autoload": { + "files": [ + "function.php" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "A generic function and convention to trigger deprecation notices", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/deprecation-contracts/tree/v3.5.1" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-25T14:20:29+00:00" + }, + { + "name": "symfony/http-foundation", + "version": "v6.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/http-foundation.git", + "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/http-foundation/zipball/431771b7a6f662f1575b3cfc8fd7617aa9864d57", + "reference": "431771b7a6f662f1575b3cfc8fd7617aa9864d57", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3", + "symfony/polyfill-mbstring": "~1.1", + "symfony/polyfill-php83": "^1.27" + }, + "conflict": { + "symfony/cache": "<6.4.12|>=7.0,<7.1.5" + }, + "require-dev": { + "doctrine/dbal": "^2.13.1|^3|^4", + "predis/predis": "^1.1|^2.0", + "symfony/cache": "^6.4.12|^7.1.5", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-kernel": "^5.4.12|^6.0.12|^6.1.4|^7.0", + "symfony/mime": "^5.4|^6.0|^7.0", + "symfony/rate-limiter": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\HttpFoundation\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Defines an object-oriented layer for the HTTP specification", + "homepage": "https://symfony.com", + "support": { + "source": "https://github.com/symfony/http-foundation/tree/v6.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-13T18:58:10+00:00" + }, + { + "name": "symfony/polyfill-mbstring", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-mbstring.git", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/85181ba99b2345b0ef10ce42ecac37612d9fd341", + "reference": "85181ba99b2345b0ef10ce42ecac37612d9fd341", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "provide": { + "ext-mbstring": "*" + }, + "suggest": { + "ext-mbstring": "For best performance" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Mbstring\\": "" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill for the Mbstring extension", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "mbstring", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/polyfill-php83", + "version": "v1.31.0", + "source": { + "type": "git", + "url": "https://github.com/symfony/polyfill-php83.git", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/polyfill-php83/zipball/2fb86d65e2d424369ad2905e83b236a8805ba491", + "reference": "2fb86d65e2d424369ad2905e83b236a8805ba491", + "shasum": "" + }, + "require": { + "php": ">=7.2" + }, + "type": "library", + "extra": { + "thanks": { + "name": "symfony/polyfill", + "url": "https://github.com/symfony/polyfill" + } + }, + "autoload": { + "files": [ + "bootstrap.php" + ], + "psr-4": { + "Symfony\\Polyfill\\Php83\\": "" + }, + "classmap": [ + "Resources/stubs" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Nicolas Grekas", + "email": "p@tchwork.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Symfony polyfill backporting some PHP 8.3+ features to lower PHP versions", + "homepage": "https://symfony.com", + "keywords": [ + "compatibility", + "polyfill", + "portable", + "shim" + ], + "support": { + "source": "https://github.com/symfony/polyfill-php83/tree/v1.31.0" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-09-09T11:45:10+00:00" + }, + { + "name": "symfony/routing", + "version": "v6.4.16", + "source": { + "type": "git", + "url": "https://github.com/symfony/routing.git", + "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/symfony/routing/zipball/91e02e606b4b705c2f4fb42f7e7708b7923a3220", + "reference": "91e02e606b4b705c2f4fb42f7e7708b7923a3220", + "shasum": "" + }, + "require": { + "php": ">=8.1", + "symfony/deprecation-contracts": "^2.5|^3" + }, + "conflict": { + "doctrine/annotations": "<1.12", + "symfony/config": "<6.2", + "symfony/dependency-injection": "<5.4", + "symfony/yaml": "<5.4" + }, + "require-dev": { + "doctrine/annotations": "^1.12|^2", + "psr/log": "^1|^2|^3", + "symfony/config": "^6.2|^7.0", + "symfony/dependency-injection": "^5.4|^6.0|^7.0", + "symfony/expression-language": "^5.4|^6.0|^7.0", + "symfony/http-foundation": "^5.4|^6.0|^7.0", + "symfony/yaml": "^5.4|^6.0|^7.0" + }, + "type": "library", + "autoload": { + "psr-4": { + "Symfony\\Component\\Routing\\": "" + }, + "exclude-from-classmap": [ + "/Tests/" + ] + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Fabien Potencier", + "email": "fabien@symfony.com" + }, + { + "name": "Symfony Community", + "homepage": "https://symfony.com/contributors" + } + ], + "description": "Maps an HTTP request to a set of configuration variables", + "homepage": "https://symfony.com", + "keywords": [ + "router", + "routing", + "uri", + "url" + ], + "support": { + "source": "https://github.com/symfony/routing/tree/v6.4.16" + }, + "funding": [ + { + "url": "https://symfony.com/sponsor", + "type": "custom" + }, + { + "url": "https://github.com/fabpot", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", + "type": "tidelift" + } + ], + "time": "2024-11-13T15:31:34+00:00" } ], "packages-dev": [], @@ -301,12 +1907,12 @@ "prefer-stable": false, "prefer-lowest": false, "platform": { - "php": ">=8.1" + "php": ">=8.2" }, "platform-dev": [], "platform-overrides": { - "php": "8.1", + "php": "8.2", "ext-zmq": "1" }, - "plugin-api-version": "1.1.0" + "plugin-api-version": "2.3.0" } diff --git a/docker-compose.yml b/docker-compose.yml index 29589cc..74ec577 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -3,6 +3,9 @@ version: "3" services: xmr: build: . + ports: + - "8080:8080" + - "8081:8081" environment: XMR_DEBUG: "true" volumes: diff --git a/entrypoint.sh b/entrypoint.sh index 039ab5a..adfeb5b 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -2,12 +2,14 @@ # Write config.json echo '{' > /opt/xmr/config.json -echo ' "listenOn": "tcp://*:50001",' >> /opt/xmr/config.json -echo ' "pubOn": ["tcp://*:9505"],' >> /opt/xmr/config.json +echo ' "sockets": {' >> /opt/xmr/config.json +echo ' "ws": "0.0.0.0:8080",' >> /opt/xmr/config.json +echo ' "api": "0.0.0.0:8081",' >> /opt/xmr/config.json +echo ' "zmq": ["tcp://*:9505"]' >> /opt/xmr/config.json +echo ' },' >> /opt/xmr/config.json echo ' "queuePoll": '$XMR_QUEUE_POLL',' >> /opt/xmr/config.json echo ' "queueSize": '$XMR_QUEUE_SIZE',' >> /opt/xmr/config.json echo ' "debug": '$XMR_DEBUG',' >> /opt/xmr/config.json -echo ' "ipv6RespSupport": '$XMR_IPV6RESPSUPPORT',' >> /opt/xmr/config.json echo ' "ipv6PubSupport": '$XMR_IPV6PUBSUPPORT >> /opt/xmr/config.json echo '}' >> /opt/xmr/config.json diff --git a/index.php b/index.php index 3303339..0ae48fb 100644 --- a/index.php +++ b/index.php @@ -1,9 +1,9 @@ #!/usr/bin/env php . - * -sequenceDiagram -Player->> CMS: Register -Note right of Player: Register contains the XMR Channel -CMS->> XMR: PlayerAction -XMR->> CMS: ACK -XMR-->> Player: PlayerAction - * */ + +use Monolog\Handler\StreamHandler; +use Monolog\Logger; +use Ratchet\Http\HttpServer; +use Ratchet\Server\IoServer; +use Ratchet\WebSocket\WsServer; +use React\EventLoop\Loop; +use React\Http\Message\Response; +use Xibo\Controller\Api; +use Xibo\Controller\Server; +use Xibo\Entity\Queue; + require 'vendor/autoload.php'; -function exception_error_handler($severity, $message, $file, $line) { +// TODO: ratchet does not support PHP8 +error_reporting(E_ALL ^ E_DEPRECATED); + +set_error_handler(function($severity, $message, $file, $line) { if (!(error_reporting() & $severity)) { // This error code is not included in error_reporting return; } throw new ErrorException($message, 0, $severity, $file, $line); -} -set_error_handler("exception_error_handler"); +}); // Decide where to look for the config file $dirname = (Phar::running(false) == '') ? __DIR__ : dirname(Phar::running(false)); $config = $dirname . '/config.json'; -if (!file_exists($config)) +if (!file_exists($config)) { throw new InvalidArgumentException('Missing ' . $config . ' file, please create one in ' . $dirname); +} $configString = file_get_contents($config); $config = json_decode($configString); -if ($config === null) +if ($config === null) { throw new InvalidArgumentException('Cannot decode config file ' . json_last_error_msg() . ' config string is [' . $configString . ']'); +} + +$logLevel = $config->debug ? Logger::DEBUG : Logger::WARNING; -if ($config->debug) - $logLevel = \Monolog\Logger::DEBUG; -else - $logLevel = \Monolog\Logger::WARNING; +// Set up logging to file +$log = new Logger('xmr'); +$log->pushHandler(new StreamHandler(STDOUT, $logLevel)); // Queue settings $queuePoll = (property_exists($config, 'queuePoll')) ? $config->queuePoll : 5; $queueSize = (property_exists($config, 'queueSize')) ? $config->queueSize : 10; -// Set up logging to file -$log = new \Monolog\Logger('xmr'); -$log->pushHandler(new \Monolog\Handler\StreamHandler(STDOUT, $logLevel)); -$log->info(sprintf('Starting up - listening for CMS on %s.', $config->listenOn)); +// Create an in memory message queue. +$messageQueue = new Queue(); try { - $loop = \React\EventLoop\Factory::create(); - - /** - * ZMQ context wraps the PHP implementation. - * @var \ZMQContext $context - */ - $context = new React\ZMQ\Context($loop); - - // Reply socket for requests from CMS - $responder = $context->getSocket(ZMQ::SOCKET_REP); - $responder->bind($config->listenOn); - - // Set RESP socket options - if (isset($config->ipv6RespSupport) && $config->ipv6RespSupport === true) { - $log->debug('RESP MQ Setting socket option for IPv6 to TRUE'); - $responder->setSockOpt(\ZMQ::SOCKOPT_IPV6, true); - } + $loop = Loop::get(); - // Pub socket for messages to Players (subs) - $publisher = $context->getSocket(ZMQ::SOCKET_PUB); + // Web Socket server + $messagingServer = new Server($messageQueue, $log); + $wsSocket = new React\Socket\SocketServer($config->sockets->ws); + $wsServer = new WsServer($messagingServer); + $ioServer = new IoServer( + new HttpServer($wsServer), + $wsSocket, + $loop + ); + + // Enable keep alive + $wsServer->enableKeepAlive($ioServer->loop); + + $log->info('WS listening on ' . $config->sockets->ws); + + // LEGACY: Pub socket for messages to Players (subs) + $publisher = (new React\ZMQ\Context($loop))->getSocket(ZMQ::SOCKET_PUB); // Set PUB socket options if (isset($config->ipv6PubSupport) && $config->ipv6PubSupport === true) { @@ -94,149 +100,104 @@ function exception_error_handler($severity, $message, $file, $line) { $publisher->setSockOpt(\ZMQ::SOCKOPT_IPV6, true); } - foreach ($config->pubOn as $pubOn) { + foreach ($config->sockets->zmq as $pubOn) { $log->info(sprintf('Bind to %s for Publish.', $pubOn)); $publisher->bind($pubOn); } - // Create an in memory message queue. - $messageStatsEmpty = [ - 'peakQueueSize' => 0, - 'messageCounters' => [ - 'total' => 0, - 'sent' => 0, - 'qos1' => 0, - 'qos2' => 0, - 'qos3' => 0, - 'qos4' => 0, - 'qos5' => 0, - 'qos6' => 0, - 'qos7' => 0, - 'qos8' => 0, - 'qos9' => 0, - 'qos10' => 0, - ] - ]; - $messageStats = $messageStatsEmpty; - $messageQueue = []; - - // REP - $responder->on('error', function ($e) use ($log) { - $log->error($e->getMessage()); - }); - - $responder->on('message', function ($msg) use ($log, $responder, $publisher, &$messageQueue, &$messageStats, $messageStatsEmpty) { + // Create a private API to receive messages from the CMS + $api = new Api($messageQueue, $log); + // Create a HTTP server to handle requests to the API + $http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) use ($log, $api) { try { - // Log incoming message - $log->info($msg); - - if ($msg === 'stats') { - // Add the current queue size - $messageStats['currentQueueSize'] = count($messageQueue); - - // Send response - $responder->send(json_encode($messageStats), \ZMQ::MODE_DONTWAIT); - - // Reset the stats - $messageStats = $messageStatsEmpty; - } else { - // Parse the message and expect a "channel" element - $msg = json_decode($msg); - - if (!isset($msg->channel)) { - throw new InvalidArgumentException('Missing Channel'); - } - - if (!isset($msg->key)) { - throw new InvalidArgumentException('Missing Key'); - } - - if (!isset($msg->message)) { - throw new InvalidArgumentException('Missing Message'); - } - - // Respond to this message - $responder->send(true, \ZMQ::MODE_DONTWAIT); - - // Make sure QOS is set - if (!isset($msg->qos)) { - // Default to the highest priority for messages missing a QOS - $msg->qos = 10; - } - - // Add to stats - $messageStats['messageCounters']['total']++; - $messageStats['messageCounters']['qos' . $msg->qos]++; - - // Decide whether we should queue the message or send it immediately. - if ($msg->qos != 10) { - // Queue for the periodic poll to send - $log->debug('Queuing'); - $messageQueue[] = $msg; + if ($request->getMethod() !== 'POST') { + throw new Exception('Method not allowed'); + } - // Record peak queue - $currentQueueSize = count($messageQueue); - if ($currentQueueSize > $messageStats['peakQueueSize']) { - $messageStats['peakQueueSize'] = $currentQueueSize; - } - } else { - // Send Immediately - $log->debug('Sending Immediately'); - $messageStats['messageCounters']['sent']++; - $publisher->sendmulti([$msg->channel, $msg->key, $msg->message], \ZMQ::MODE_DONTWAIT); - } + $json = json_decode($request->getBody()->getContents(), true); + if ($json === false || !is_array($json)) { + throw new InvalidArgumentException('Not valid JSON'); } - } catch (InvalidArgumentException $e) { - // Return false - $responder->send(false, \ZMQ::MODE_DONTWAIT); - $log->error($e->getMessage()); + return $api->handleMessage($json); + } catch (Exception $e) { + $log->error('API: e = ' . $e->getMessage()); + return new Response( + 422, + ['Content-Type' => 'plain/text'], + $e->getMessage() + ); } }); + $socket = new React\Socket\SocketServer($config->sockets->api); + $http->listen($socket); + $http->on('error', function (Exception $exception) use ($log) { + $log->error('http: ' . $exception->getMessage()); + $log->debug('stack: ' . $exception->getTraceAsString()); + }); + + $log->info('HTTP listening'); // Queue Processor $log->debug('Adding a queue processor for every ' . $queuePoll . ' seconds'); - $loop->addPeriodicTimer($queuePoll, function() use ($log, $publisher, &$messageQueue, $queueSize, &$messageStats) { + $loop->addPeriodicTimer($queuePoll, function() use ($log, $messagingServer, $publisher, $messageQueue, $queueSize) { // Is there work to be done - if (count($messageQueue) > 0) { + if ($messageQueue->hasItems()) { $log->debug('Queue Poll - work to be done.'); - // Order the message queue according to QOS - usort($messageQueue, function($a, $b) { - return ($a->qos === $b->qos) ? 0 : (($a->qos < $b->qos) ? -1 : 1); - }); + $messageQueue->sortQueue(); $log->debug('Queue Poll - message queue sorted'); // Send up to X messages. for ($i = 0; $i < $queueSize; $i++) { - if ($i > count($messageQueue)) { + if ($i > $messageQueue->queueSize()) { $log->debug('Queue Poll - queue size reached'); break; } // Pop an element - $msg = array_pop($messageQueue); + $msg = $messageQueue->getItem(); // Send $log->debug('Sending ' . $i); - $messageStats['messageCounters']['sent']++; - $publisher->sendmulti([$msg->channel, $msg->key, $msg->message], \ZMQ::MODE_DONTWAIT); + // Where are we sending this item? + if ($msg->isWebSocket) { + $display = $messagingServer->getDisplayById($msg->channel); + if ($display === null) { + $log->info('Display ' . $msg->channel . ' not connected'); + continue; + } + $display->connection->send($msg->message); + } else { + $publisher->sendmulti([$msg->channel, $msg->key, $msg->message], \ZMQ::MODE_DONTWAIT); + } - $log->debug('Popped ' . $i . ' from the queue, new queue size ' . count($messageQueue)); + $log->debug('Popped ' . $i . ' from the queue, new queue size ' . $messageQueue->queueSize()); } } }); // Periodic updater - $loop->addPeriodicTimer(30, function() use ($log, $publisher) { + $loop->addPeriodicTimer(30, function() use ($log, $messagingServer, $publisher) { $log->debug('Heartbeat...'); + + // Send to all connected WS clients + $messagingServer->heartbeat(); + + // Send to PUB queue $publisher->sendmulti(["H", "", ""], \ZMQ::MODE_DONTWAIT); }); - // Run the react event loop + // Key management + $loop->addPeriodicTimer(3600, function() use ($log, $messageQueue) { + $log->debug('Key management...'); + $messageQueue->expireKeys(); + }); + + // Run the React event loop $loop->run(); } catch (Exception $e) { $log->error($e->getMessage()); diff --git a/src/Controller/Api.php b/src/Controller/Api.php new file mode 100644 index 0000000..655e955 --- /dev/null +++ b/src/Controller/Api.php @@ -0,0 +1,66 @@ +. + */ +namespace Xibo\Controller; + +use Psr\Log\LoggerInterface; +use React\Http\Message\Response; +use Xibo\Entity\Queue; + +class Api +{ + public function __construct( + private readonly Queue $queue, + private readonly LoggerInterface $logger + ) { + } + + /** + * Handle messages hitting the API + * @param array $message + * @return \React\Http\Message\Response + */ + public function handleMessage(array $message): Response + { + $type = $message['type'] ?? 'empty'; + + $this->logger->debug('handleMessage: type = ' . $type); + + if ($type === 'stats') { + // Success + return Response::json($this->queue->flushStats()); + } else if ($type === 'keys') { + // Register new keys for this CMS. + $this->queue->addKey($message['id'], $message['key']); + } else if ($type === 'multi') { + $this->logger->debug('Queuing multiple messages'); + foreach ($message['messages'] as $message) { + $this->queue->queueItem($message); + } + } else { + $this->logger->debug('Queuing'); + $this->queue->queueItem($message); + } + + // Success + return new Response(201); + } +} diff --git a/src/Controller/Server.php b/src/Controller/Server.php new file mode 100644 index 0000000..82df95f --- /dev/null +++ b/src/Controller/Server.php @@ -0,0 +1,180 @@ +. + */ +namespace Xibo\Controller; + +use Psr\Log\LoggerInterface; +use Ratchet\ConnectionInterface; +use Ratchet\MessageComponentInterface; +use Xibo\Entity\Display; +use Xibo\Entity\Queue; +use XiboSignage\Client\Client; + +class Server implements MessageComponentInterface +{ + /** @var Display[] */ + private array $displays = []; + private array $ids = []; + + public function __construct( + private readonly Queue $queue, + private readonly LoggerInterface $logger + ) { + } + + public function onOpen(ConnectionInterface $conn): void + { + $this->logger->debug('onOpen: ' . $conn->resourceId); + + $this->addDisplay( + $conn->resourceId, + $conn + ); + } + + public function onClose(ConnectionInterface $conn): void + { + $this->removeDisplay($conn->resourceId); + $this->logger->debug('onClose: ' . $conn->resourceId); + } + + public function onError(ConnectionInterface $conn, \Exception $e): void + { + $this->logger->debug('onError: ' . $conn->resourceId . ', e: ' . $e->getMessage()); + } + + public function onMessage(ConnectionInterface $from, $msg): void + { + $display = $this->getDisplayByResourceId($from->resourceId); + + $this->logger->debug('onMessage: ' . $display->resourceId); + + // Expect a JSON string + $json = json_decode($msg, true); + if ($json === null) { + $this->logger->error('onMessage: Invalid JSON'); + return; + } + + // We are only expecting one message, which initialises the connection. + try { + if (($json['type'] ?? 'empty') === 'init') { + // The display should pass us a key + $key = $json['key'] ?? null; + if (empty($key)) { + throw new \InvalidArgumentException('Missing key'); + } + + $channel = $json['channel'] ?? null; + if (empty($channel)) { + throw new \InvalidArgumentException('Missing channel'); + } + + // Validate the key provided + if (!$this->queue->authKey($key)) { + throw new \InvalidArgumentException('Invalid key'); + } + + // Valid key for the CMS + $this->linkDisplay($display, $channel); + } else { + throw new \Exception('Invalid message type'); + } + } catch (\Exception $e) { + $this->logger->error('onMessage: ' . $e->getMessage()); + + // Close the socket with an error (onClose gets called to remove the connection) + $display->connection->close(); + } + } + + public function heartbeat(): void + { + foreach ($this->displays as $display) { + if ($display->id !== null) { + $display->connection->send('H'); + } + } + } + + /** + * Add a display to the list of connections (unauthed at this point) + * @param string $resourceId + * @param \Ratchet\ConnectionInterface $connection + * @return \Xibo\Entity\Display + */ + private function addDisplay(string $resourceId, ConnectionInterface $connection): Display + { + $this->displays[$resourceId] = new Display($resourceId, $connection); + return $this->displays[$resourceId]; + } + + /** + * Link a display to an ID (which is the channel) + * @param \Xibo\Entity\Display $display + * @param string $id + * @return void + */ + private function linkDisplay(Display $display, string $id): void + { + // Make a pointer between this resource and the ID + $this->ids[$id] = $display->resourceId; + $display->id = $id; + } + + /** + * Remove a display + * @param string $resourceId + * @return void + */ + private function removeDisplay(string $resourceId): void + { + $display = $this->getDisplayByResourceId($resourceId); + if ($display !== null && $display->id !== null) { + unset($this->ids[$display->id]); + } + unset($this->displays[$resourceId]); + } + + /** + * Get a display by its ID (channel) + * @param string $id + * @return \Xibo\Entity\Display|null + */ + public function getDisplayById(string $id): ?Display + { + if (isset($this->ids[$id])) { + return $this->displays[$this->ids[$id]] ?? null; + } else { + return null; + } + } + + /** + * Get a display by its socket resource + * @param string $resourceId + * @return \Xibo\Entity\Display|null + */ + private function getDisplayByResourceId(string $resourceId): ?Display + { + return $this->displays[$resourceId] ?? null; + } +} diff --git a/src/Entity/Display.php b/src/Entity/Display.php new file mode 100644 index 0000000..2b7fe84 --- /dev/null +++ b/src/Entity/Display.php @@ -0,0 +1,35 @@ +. + */ +namespace Xibo\Entity; + +use Ratchet\ConnectionInterface; + +class Display +{ + public ?string $id = null; + + public function __construct( + public string $resourceId, + public ConnectionInterface $connection + ) { + } +} diff --git a/src/Entity/Message.php b/src/Entity/Message.php new file mode 100644 index 0000000..722e8bd --- /dev/null +++ b/src/Entity/Message.php @@ -0,0 +1,31 @@ +. + */ +namespace Xibo\Entity; + +class Message +{ + public string $channel; + public string $key; + public string $message; + public int $qos; + public bool $isWebSocket; +} diff --git a/src/Entity/Queue.php b/src/Entity/Queue.php new file mode 100644 index 0000000..5eba1d1 --- /dev/null +++ b/src/Entity/Queue.php @@ -0,0 +1,200 @@ +. + */ + +namespace Xibo\Entity; + +class Queue +{ + private array $instances = []; + + /** @var \Xibo\Entity\Message[] */ + private array $queue; + + private array $stats; + + public function __construct() + { + $this->queue = []; + $this->stats = [ + 'peakQueueSize' => 0, + 'messageCounters' => [ + 'total' => 0, + 'sent' => 0, + 'qos1' => 0, + 'qos2' => 0, + 'qos3' => 0, + 'qos4' => 0, + 'qos5' => 0, + 'qos6' => 0, + 'qos7' => 0, + 'qos8' => 0, + 'qos9' => 0, + 'qos10' => 0, + ] + ]; + + } + + public function hasItems(): bool + { + return count($this->queue); + } + + public function queueSize(): int + { + return count($this->queue); + } + + public function sortQueue(): void + { + // Order the message queue according to QOS + usort($this->queue, function($a, $b) { + return ($a->qos === $b->qos) ? 0 : (($a->qos < $b->qos) ? -1 : 1); + }); + } + + public function getItem(): Message + { + $this->stats['messageCounters']['sent']++; + + return array_pop($this->queue); + } + + /** + * @param array $message + * @return void + * @throws \InvalidArgumentException + */ + public function queueItem(array $message): void + { + $msg = new Message(); + + if (!isset($message['channel'])) { + throw new \InvalidArgumentException('Missing Channel'); + } + + if (!isset($message['key'])) { + throw new \InvalidArgumentException('Missing Key'); + } + + if (!isset($message['message'])) { + throw new \InvalidArgumentException('Missing Message'); + } + + // Make sure QOS is set + if (!isset($message['qos'])) { + // Default to the highest priority for messages missing a QOS + $message['qos'] = 10; + } + + $msg->channel = $message['channel']; + $msg->key = $message['key']; + $msg->message = $message['message']; + $msg->qos = $message['qos']; + $msg->isWebSocket = $message['isWebSocket'] ?? false; + + // Queue + $this->queue[] = $msg; + + // Update stats + $this->stats['messageCounters']['total']++; + $this->stats['messageCounters']['qos' . $msg->qos]++; + + $currentQueueSize = $this->queueSize(); + if ($currentQueueSize > $this->stats['peakQueueSize']) { + $this->stats['peakQueueSize'] = $currentQueueSize; + } + } + + public function flushStats(): array + { + $stats = $this->stats; + $stats['currentQueueSize'] = $this->queueSize(); + $this->clearStats(); + return $stats; + } + + private function clearStats(): void + { + $this->stats = [ + 'peakQueueSize' => 0, + 'messageCounters' => [ + 'total' => 0, + 'sent' => 0, + 'qos1' => 0, + 'qos2' => 0, + 'qos3' => 0, + 'qos4' => 0, + 'qos5' => 0, + 'qos6' => 0, + 'qos7' => 0, + 'qos8' => 0, + 'qos9' => 0, + 'qos10' => 0, + ] + ]; + } + + public function addKey(string $instance, string $key): void + { + if (!array_key_exists($instance, $this->instances)) { + $this->instances[$instance] = ['keys' => []]; + } + $this->instances[$instance]['keys'][] = [ + 'key' => $key, + 'expires' => time() + 86400, + ]; + } + + public function authKey(string $providedKey): bool + { + foreach ($this->instances as $instance) { + foreach ($instance['keys'] as $key) { + if ($key['key'] === $providedKey && time() < $key['expires']) { + return true; + } + } + } + + return false; + } + + public function expireKeys(): void + { + // Expire keys within each instance + foreach ($this->instances as $instance) { + for ($i = 0; $i < count($instance['keys']); $i++) { + // Expire any keys which are no longer in date. + if (time() >= $instance['keys'][$i]['expires']) { + unset($instance['keys'][$i]); + } + } + } + + // Remove instances with no keys + for ($j = 0; $j < count($this->instances); $j++) { + if (count($this->instances[$j]['keys']) <= 0) { + unset($this->instances[$j]); + } + } + } +} diff --git a/tests/Private API.http b/tests/Private API.http new file mode 100644 index 0000000..15a61ec --- /dev/null +++ b/tests/Private API.http @@ -0,0 +1,19 @@ +POST http://localhost:8081 +Content-Type: application/json + +{ + "type": "stats" +} + +### + +POST http://localhost:8081 +Content-Type: application/json + +{ + "type": "keys", + "id": "http://localhost", + "key": "123456" +} + +### diff --git a/tests/cmsGetStats.php b/tests/cmsGetStats.php deleted file mode 100644 index 2078530..0000000 --- a/tests/cmsGetStats.php +++ /dev/null @@ -1,85 +0,0 @@ -. - * - * This is a CMS send MOCK - * execute with: docker-compose exec xmr sh -c "cd /opt/xmr/tests; php cmsGetStats.php" - * - */ -require '../vendor/autoload.php'; - -try { - // Create a message and send. - send('tcp://localhost:50001', 'stats'); -} catch (Exception $e) { - echo $e->getMessage() . PHP_EOL; -} - -/** - * @param $connection - * @param $message - * @return bool|string - * @throws ZMQSocketException - */ -function send($connection, $message) -{ - echo 'Sending to ' . $connection . PHP_EOL; - - // Issue a message payload to XMR. - $context = new \ZMQContext(); - - // Connect to socket - $socket = new \ZMQSocket($context, \ZMQ::SOCKET_REQ); - $socket->connect($connection); - - // Send the message to the socket - $socket->send($message); - - // Need to replace this with a non-blocking recv() with a retry loop - $retries = 15; - $reply = false; - - do { - try { - // Try and receive - // if ZMQ::MODE_NOBLOCK/MODE_DONTWAIT is used and the operation would block boolean false - // shall be returned. - $reply = $socket->recv(\ZMQ::MODE_DONTWAIT); - - echo 'Received ' . var_export($reply, true) . PHP_EOL; - - if ($reply !== false) { - break; - } - } catch (\ZMQSocketException $sockEx) { - if ($sockEx->getCode() !== \ZMQ::ERR_EAGAIN) { - throw $sockEx; - } - } - - usleep(100000); - - } while (--$retries); - - // Disconnect socket - $socket->disconnect($connection); - - return $reply; -} \ No newline at end of file diff --git a/tests/cmsSend.php b/tests/cmsSend.php index 9100858..01cdf25 100644 --- a/tests/cmsSend.php +++ b/tests/cmsSend.php @@ -1,8 +1,8 @@ . - * - * This is a CMS send MOCK - * execute with: docker-compose exec xmr sh -c "cd /opt/xmr/tests; php cmsSend.php 1234" - * */ + +// execute with: docker-compose exec xmr sh -c "cd /opt/xmr/tests; php cmsSend.php 1234" require '../vendor/autoload.php'; $_MESSAGE_COUNT = 15; -$_ENCRYPT = true; +$_ENCRYPT = false; // Track $start = microtime(true); @@ -35,6 +33,7 @@ } $identity = $argv[1]; +$isWebSocket = ($argv[2] ?? false) === 'websocket'; // Get the Public Key $fp = fopen('key.pub', 'r'); @@ -42,12 +41,16 @@ fclose($fp); try { - // Issue a message payload to XMR. - $context = new \ZMQContext(); + //open connection + $ch = curl_init(); + + //set the url, number of POST vars, POST data + curl_setopt($ch,CURLOPT_URL, 'http://localhost:8081'); + curl_setopt($ch,CURLOPT_POST, true); + curl_setopt( $ch, CURLOPT_HTTPHEADER, array('Content-Type:application/json')); - // Connect to socket - $socket = new \ZMQSocket($context, \ZMQ::SOCKET_REQ); - $socket->connect('tcp://localhost:50001'); + // So that curl_exec returns the contents of the cURL; rather than echoing it + curl_setopt($ch,CURLOPT_RETURNTRANSFER, true); // Queue up a bunch of messages to see what happens for ($i = 0; $i < $_MESSAGE_COUNT; $i++) { @@ -60,26 +63,32 @@ openssl_seal($i . ' - QOS1', $message, $eKeys, [$publicKey], 'RC4'); // Create a message and send. - send($socket, [ + $fields = [ 'channel' => $identity, 'key' => base64_encode($eKeys[0]), 'message' => base64_encode($message), - 'qos' => rand(1, 10) - ]); + 'qos' => rand(1, 10), + 'isWebSocket' => $isWebSocket, + ]; } else { - send($socket, [ + $fields = [ 'channel' => $identity, 'key' => 'key', 'message' => 'message ' . $i, - 'qos' => rand(1, 10) - ]); + 'qos' => rand(1, 10), + 'isWebSocket' => $isWebSocket, + ]; } + + curl_setopt($ch,CURLOPT_POSTFIELDS, json_encode($fields)); + + //execute post + $result = curl_exec($ch); + echo $result . PHP_EOL; + usleep(50); } - - // Disconnect socket - $socket->disconnect('tcp://localhost:50001'); } catch (Exception $e) { echo $e->getMessage() . PHP_EOL; } diff --git a/tests/playerReq.php b/tests/playerReq.php deleted file mode 100644 index 02e2644..0000000 --- a/tests/playerReq.php +++ /dev/null @@ -1,19 +0,0 @@ - -*/ - -$context = new ZMQContext(); - -// Socket to talk to server -echo "Connecting to hello world server…\n"; -$requester = new ZMQSocket($context, ZMQ::SOCKET_REQ); -$requester->connect("tcp://192.168.86.88:58587"); -echo "connected\n"; -$requester->send("Hello"); -echo "sent\n"; -$reply = $requester->recv(); -echo "Received reply " . $reply; \ No newline at end of file diff --git a/tests/playerSub.php b/tests/playerSub.php index 6b9aed1..3481e17 100644 --- a/tests/playerSub.php +++ b/tests/playerSub.php @@ -1,8 +1,8 @@ . - * - * This is a player subscription mock file. - * docker-compose exec xmr sh -c "cd /opt/xmr/tests; php playerSub.php 1234" - * */ +// docker-compose exec xmr sh -c "cd /opt/xmr/tests; php playerSub.php 1234" +// docker-compose exec xmr sh -c "cd /opt/xmr/tests; php playerSub.php 1234 websocket" require '../vendor/autoload.php'; if (!isset($argv[1])) { @@ -35,7 +33,7 @@ $privateKey = openssl_get_privatekey(fread($fp, 8192)); fclose($fp); -echo 'Sub to: ' . $identity; +echo 'Sub to: ' . $identity . PHP_EOL; // Sub $loop = React\EventLoop\Factory::create(); From 4be42ab3114e1379f2091f8f706573f5d1e63501 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Thu, 12 Dec 2024 17:32:44 +0000 Subject: [PATCH 3/9] Fix key management routine. --- src/Entity/Queue.php | 16 +++++++--------- 1 file changed, 7 insertions(+), 9 deletions(-) diff --git a/src/Entity/Queue.php b/src/Entity/Queue.php index 5eba1d1..de47a38 100644 --- a/src/Entity/Queue.php +++ b/src/Entity/Queue.php @@ -181,19 +181,17 @@ public function authKey(string $providedKey): bool public function expireKeys(): void { // Expire keys within each instance - foreach ($this->instances as $instance) { - for ($i = 0; $i < count($instance['keys']); $i++) { + foreach ($this->instances as $instanceKey => $instance) { + foreach ($instance['keys'] as $key => $value) { // Expire any keys which are no longer in date. - if (time() >= $instance['keys'][$i]['expires']) { - unset($instance['keys'][$i]); + if (time() >= $value['expires']) { + unset($instance['keys'][$key]); } } - } - // Remove instances with no keys - for ($j = 0; $j < count($this->instances); $j++) { - if (count($this->instances[$j]['keys']) <= 0) { - unset($this->instances[$j]); + // Remove instances with no keys + if (count($instance['keys']) <= 0) { + unset($this->instances[$instanceKey]); } } } From 41abe2ca42ce67294c42aef5af78a9c394faa5ef Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 13 Dec 2024 08:19:17 +0000 Subject: [PATCH 4/9] Keys: if the key being added already exists, push the expiry time by 1 hour from now. --- src/Entity/Queue.php | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/Entity/Queue.php b/src/Entity/Queue.php index de47a38..66b94d5 100644 --- a/src/Entity/Queue.php +++ b/src/Entity/Queue.php @@ -154,17 +154,39 @@ private function clearStats(): void ]; } + /** + * Add key. + * called by a CMS to indicate that it has generated a new key, or is refreshing an old key. + * @param string $instance + * @param string $key + * @return void + */ public function addKey(string $instance, string $key): void { if (!array_key_exists($instance, $this->instances)) { $this->instances[$instance] = ['keys' => []]; } - $this->instances[$instance]['keys'][] = [ + + // If a key already exists push the expiry time + foreach ($this->instances[$instance]['keys'] as $existingKey) { + if ($existingKey['key'] === $key) { + $existingKey['expires'] = time() + 3600; + return; + } + } + + // Not found + $this->instances[$instance]['keys'] = [ 'key' => $key, 'expires' => time() + 86400, ]; } + /** + * Authenticate the provided key against our list of valid keys + * @param string $providedKey + * @return bool + */ public function authKey(string $providedKey): bool { foreach ($this->instances as $instance) { @@ -178,6 +200,10 @@ public function authKey(string $providedKey): bool return false; } + /** + * Key maintenance to remove keys which have expired + * @return void + */ public function expireKeys(): void { // Expire keys within each instance From 518e566fdd9d708a8525731409b2c5e1dc4795e2 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 13 Dec 2024 08:43:38 +0000 Subject: [PATCH 5/9] Keys: fix type error and actually write expiry time. --- index.php | 2 +- src/Entity/Queue.php | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/index.php b/index.php index 0ae48fb..df99b4c 100644 --- a/index.php +++ b/index.php @@ -36,6 +36,7 @@ // TODO: ratchet does not support PHP8 error_reporting(E_ALL ^ E_DEPRECATED); +ini_set('display_errors', 0); set_error_handler(function($severity, $message, $file, $line) { if (!(error_reporting() & $severity)) { @@ -134,7 +135,6 @@ $http->listen($socket); $http->on('error', function (Exception $exception) use ($log) { $log->error('http: ' . $exception->getMessage()); - $log->debug('stack: ' . $exception->getTraceAsString()); }); $log->info('HTTP listening'); diff --git a/src/Entity/Queue.php b/src/Entity/Queue.php index 66b94d5..a9e0b55 100644 --- a/src/Entity/Queue.php +++ b/src/Entity/Queue.php @@ -168,15 +168,15 @@ public function addKey(string $instance, string $key): void } // If a key already exists push the expiry time - foreach ($this->instances[$instance]['keys'] as $existingKey) { + foreach ($this->instances[$instance]['keys'] as $index => $existingKey) { if ($existingKey['key'] === $key) { - $existingKey['expires'] = time() + 3600; + $this->instances[$instance]['keys'][$index]['expires'] = time() + 3600; return; } } // Not found - $this->instances[$instance]['keys'] = [ + $this->instances[$instance]['keys'][] = [ 'key' => $key, 'expires' => time() + 86400, ]; From a776f7d37f2daeaeeddf490ee95bbf3a5099f5ab Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Fri, 27 Dec 2024 16:14:11 +0000 Subject: [PATCH 6/9] XMR: implement 2 relay modes (one for horizontal scaling of new XMR, one for handling old XMR messages) --- Dockerfile | 2 + composer.json | 3 +- composer.lock | 263 ++++++++++++++++++++++++++++++++++++++- entrypoint.sh | 25 +++- index.php | 108 ++++++++++------ src/Controller/Api.php | 8 +- src/Controller/Relay.php | 128 +++++++++++++++++++ src/Entity/Message.php | 13 +- 8 files changed, 507 insertions(+), 43 deletions(-) create mode 100644 src/Controller/Relay.php diff --git a/Dockerfile b/Dockerfile index faff564..3bd181e 100644 --- a/Dockerfile +++ b/Dockerfile @@ -9,6 +9,8 @@ ENV XMR_DEBUG=false ENV XMR_QUEUE_POLL=5 ENV XMR_QUEUE_SIZE=10 ENV XMR_IPV6PUBSUPPORT=false +ENV XMR_RELAY_OLD_MESSAGES=false +ENV XMR_RELAY_MESSAGES=false RUN apt-get update && apt-get install -y libzmq3-dev git \ && rm -rf /var/lib/apt/lists/* diff --git a/composer.json b/composer.json index 739f8a4..967997a 100644 --- a/composer.json +++ b/composer.json @@ -31,7 +31,8 @@ "react/react": "^1.4", "react/socket": "^1.16", "react/zmq": "^0.4.0", - "cboden/ratchet": "^0.4.4" + "cboden/ratchet": "^0.4.4", + "guzzlehttp/guzzle": "^7.9" }, "autoload": { "psr-4": { diff --git a/composer.lock b/composer.lock index 226ad28..3b65780 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "2962bb0981206b9c30166891cdec6983", + "content-hash": "58cf90f353cd3f8d2862b0ebb1dff300", "packages": [ { "name": "cboden/ratchet", @@ -172,6 +172,215 @@ }, "time": "2020-11-24T22:02:12+00:00" }, + { + "name": "guzzlehttp/guzzle", + "version": "7.9.2", + "source": { + "type": "git", + "url": "https://github.com/guzzle/guzzle.git", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/guzzle/zipball/d281ed313b989f213357e3be1a179f02196ac99b", + "reference": "d281ed313b989f213357e3be1a179f02196ac99b", + "shasum": "" + }, + "require": { + "ext-json": "*", + "guzzlehttp/promises": "^1.5.3 || ^2.0.3", + "guzzlehttp/psr7": "^2.7.0", + "php": "^7.2.5 || ^8.0", + "psr/http-client": "^1.0", + "symfony/deprecation-contracts": "^2.2 || ^3.0" + }, + "provide": { + "psr/http-client-implementation": "1.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "ext-curl": "*", + "guzzle/client-integration-tests": "3.0.2", + "php-http/message-factory": "^1.1", + "phpunit/phpunit": "^8.5.39 || ^9.6.20", + "psr/log": "^1.1 || ^2.0 || ^3.0" + }, + "suggest": { + "ext-curl": "Required for CURL handler support", + "ext-intl": "Required for Internationalized Domain Name (IDN) support", + "psr/log": "Required for using the Log middleware" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "files": [ + "src/functions_include.php" + ], + "psr-4": { + "GuzzleHttp\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Jeremy Lindblom", + "email": "jeremeamia@gmail.com", + "homepage": "https://github.com/jeremeamia" + }, + { + "name": "George Mponos", + "email": "gmponos@gmail.com", + "homepage": "https://github.com/gmponos" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Márk Sági-Kazár", + "email": "mark.sagikazar@gmail.com", + "homepage": "https://github.com/sagikazarmark" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle is a PHP HTTP client library", + "keywords": [ + "client", + "curl", + "framework", + "http", + "http client", + "psr-18", + "psr-7", + "rest", + "web service" + ], + "support": { + "issues": "https://github.com/guzzle/guzzle/issues", + "source": "https://github.com/guzzle/guzzle/tree/7.9.2" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle", + "type": "tidelift" + } + ], + "time": "2024-07-24T11:22:20+00:00" + }, + { + "name": "guzzlehttp/promises", + "version": "2.0.4", + "source": { + "type": "git", + "url": "https://github.com/guzzle/promises.git", + "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/guzzle/promises/zipball/f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "reference": "f9c436286ab2892c7db7be8c8da4ef61ccf7b455", + "shasum": "" + }, + "require": { + "php": "^7.2.5 || ^8.0" + }, + "require-dev": { + "bamarni/composer-bin-plugin": "^1.8.2", + "phpunit/phpunit": "^8.5.39 || ^9.6.20" + }, + "type": "library", + "extra": { + "bamarni-bin": { + "bin-links": true, + "forward-command": false + } + }, + "autoload": { + "psr-4": { + "GuzzleHttp\\Promise\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Graham Campbell", + "email": "hello@gjcampbell.co.uk", + "homepage": "https://github.com/GrahamCampbell" + }, + { + "name": "Michael Dowling", + "email": "mtdowling@gmail.com", + "homepage": "https://github.com/mtdowling" + }, + { + "name": "Tobias Nyholm", + "email": "tobias.nyholm@gmail.com", + "homepage": "https://github.com/Nyholm" + }, + { + "name": "Tobias Schultze", + "email": "webmaster@tubo-world.de", + "homepage": "https://github.com/Tobion" + } + ], + "description": "Guzzle promises library", + "keywords": [ + "promise" + ], + "support": { + "issues": "https://github.com/guzzle/promises/issues", + "source": "https://github.com/guzzle/promises/tree/2.0.4" + }, + "funding": [ + { + "url": "https://github.com/GrahamCampbell", + "type": "github" + }, + { + "url": "https://github.com/Nyholm", + "type": "github" + }, + { + "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises", + "type": "tidelift" + } + ], + "time": "2024-10-17T10:06:22+00:00" + }, { "name": "guzzlehttp/psr7", "version": "2.7.0", @@ -374,6 +583,58 @@ ], "time": "2022-06-09T08:53:42+00:00" }, + { + "name": "psr/http-client", + "version": "1.0.3", + "source": { + "type": "git", + "url": "https://github.com/php-fig/http-client.git", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90", + "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90", + "shasum": "" + }, + "require": { + "php": "^7.0 || ^8.0", + "psr/http-message": "^1.0 || ^2.0" + }, + "type": "library", + "extra": { + "branch-alias": { + "dev-master": "1.0.x-dev" + } + }, + "autoload": { + "psr-4": { + "Psr\\Http\\Client\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "PHP-FIG", + "homepage": "https://www.php-fig.org/" + } + ], + "description": "Common interface for HTTP clients", + "homepage": "https://github.com/php-fig/http-client", + "keywords": [ + "http", + "http-client", + "psr", + "psr-18" + ], + "support": { + "source": "https://github.com/php-fig/http-client" + }, + "time": "2023-09-23T14:17:50+00:00" + }, { "name": "psr/http-factory", "version": "1.1.0", diff --git a/entrypoint.sh b/entrypoint.sh index adfeb5b..1d7d085 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,5 +1,26 @@ #!/bin/sh +# +# Copyright (C) 2024 Xibo Signage Ltd +# +# Xibo - Digital Signage - https://xibosignage.com +# +# This file is part of Xibo. +# +# Xibo is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# any later version. +# +# Xibo is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with Xibo. If not, see . +# + # Write config.json echo '{' > /opt/xmr/config.json echo ' "sockets": {' >> /opt/xmr/config.json @@ -10,7 +31,9 @@ echo ' },' >> /opt/xmr/config.json echo ' "queuePoll": '$XMR_QUEUE_POLL',' >> /opt/xmr/config.json echo ' "queueSize": '$XMR_QUEUE_SIZE',' >> /opt/xmr/config.json echo ' "debug": '$XMR_DEBUG',' >> /opt/xmr/config.json -echo ' "ipv6PubSupport": '$XMR_IPV6PUBSUPPORT >> /opt/xmr/config.json +echo ' "ipv6PubSupport": '$XMR_IPV6PUBSUPPORT',' >> /opt/xmr/config.json +echo ' "relayOldMessages": '$XMR_RELAY_OLD_MESSAGES',' >> /opt/xmr/config.json +echo ' "relayMessages": '$XMR_RELAY_MESSAGES >> /opt/xmr/config.json echo '}' >> /opt/xmr/config.json /usr/local/bin/php /opt/xmr/index.php \ No newline at end of file diff --git a/index.php b/index.php index df99b4c..47a81a9 100644 --- a/index.php +++ b/index.php @@ -29,6 +29,7 @@ use React\EventLoop\Loop; use React\Http\Message\Response; use Xibo\Controller\Api; +use Xibo\Controller\Relay; use Xibo\Controller\Server; use Xibo\Entity\Queue; @@ -68,8 +69,15 @@ $log->pushHandler(new StreamHandler(STDOUT, $logLevel)); // Queue settings -$queuePoll = (property_exists($config, 'queuePoll')) ? $config->queuePoll : 5; -$queueSize = (property_exists($config, 'queueSize')) ? $config->queueSize : 10; +$queuePoll = $config->queuePoll ?? 5; +$queueSize = $config->queueSize ?? 10; + +// Create a client to relay messages +$relay = new Relay( + $log, + $config->relayMessages ?? '', + $config->relayOldMessages ?? '', +); // Create an in memory message queue. $messageQueue = new Queue(); @@ -77,37 +85,10 @@ try { $loop = Loop::get(); - // Web Socket server - $messagingServer = new Server($messageQueue, $log); - $wsSocket = new React\Socket\SocketServer($config->sockets->ws); - $wsServer = new WsServer($messagingServer); - $ioServer = new IoServer( - new HttpServer($wsServer), - $wsSocket, - $loop - ); - - // Enable keep alive - $wsServer->enableKeepAlive($ioServer->loop); - - $log->info('WS listening on ' . $config->sockets->ws); - - // LEGACY: Pub socket for messages to Players (subs) - $publisher = (new React\ZMQ\Context($loop))->getSocket(ZMQ::SOCKET_PUB); - - // Set PUB socket options - if (isset($config->ipv6PubSupport) && $config->ipv6PubSupport === true) { - $log->debug('Pub MQ Setting socket option for IPv6 to TRUE'); - $publisher->setSockOpt(\ZMQ::SOCKOPT_IPV6, true); - } - - foreach ($config->sockets->zmq as $pubOn) { - $log->info(sprintf('Bind to %s for Publish.', $pubOn)); - $publisher->bind($pubOn); - } - + // Private API + // ----------- // Create a private API to receive messages from the CMS - $api = new Api($messageQueue, $log); + $api = new Api($messageQueue, $log, $relay); // Create a HTTP server to handle requests to the API $http = new React\Http\HttpServer(function (Psr\Http\Message\ServerRequestInterface $request) use ($log, $api) { @@ -139,9 +120,52 @@ $log->info('HTTP listening'); + // WS + // ---- + // Web Socket server + $messagingServer = new Server($messageQueue, $log); + $wsSocket = new React\Socket\SocketServer($config->sockets->ws); + $wsServer = new WsServer($messagingServer); + $ioServer = new IoServer( + new HttpServer($wsServer), + $wsSocket, + $loop + ); + + // Enable keep alive + $wsServer->enableKeepAlive($ioServer->loop); + + $log->info('WS listening on ' . $config->sockets->ws); + + // PUB/SUB + // ------- + // LEGACY: Pub socket for messages to Players (subs) + if ($relay->isRelayOld()) { + $log->info('Legacy: relaying old messages'); + + $publisher = null; + $relay->configureZmq(); + } else { + $log->info('Legacy: handling old messages'); + + $publisher = (new React\ZMQ\Context($loop))->getSocket(ZMQ::SOCKET_PUB); + + // Set PUB socket options + if (isset($config->ipv6PubSupport) && $config->ipv6PubSupport === true) { + $log->debug('Pub MQ Setting socket option for IPv6 to TRUE'); + $publisher->setSockOpt(\ZMQ::SOCKOPT_IPV6, true); + } + + foreach ($config->sockets->zmq as $pubOn) { + $log->info(sprintf('Bind to %s for Publish.', $pubOn)); + $publisher->bind($pubOn); + } + } + // Queue Processor + // --------------- $log->debug('Adding a queue processor for every ' . $queuePoll . ' seconds'); - $loop->addPeriodicTimer($queuePoll, function() use ($log, $messagingServer, $publisher, $messageQueue, $queueSize) { + $loop->addPeriodicTimer($queuePoll, function() use ($log, $messagingServer, $relay, $publisher, $messageQueue, $queueSize) { // Is there work to be done if ($messageQueue->hasItems()) { $log->debug('Queue Poll - work to be done.'); @@ -167,12 +191,20 @@ if ($msg->isWebSocket) { $display = $messagingServer->getDisplayById($msg->channel); if ($display === null) { - $log->info('Display ' . $msg->channel . ' not connected'); - continue; + if ($relay->isRelay()) { + $relay->relay($msg); + } else { + $log->info('Display ' . $msg->channel . ' not connected'); + } + } else { + $display->connection->send($msg->message); } - $display->connection->send($msg->message); - } else { + } else if ($relay->isRelayOld()) { + $relay->relay($msg); + } else if ($publisher !== null) { $publisher->sendmulti([$msg->channel, $msg->key, $msg->message], \ZMQ::MODE_DONTWAIT); + } else { + $log->error('No route to send'); } $log->debug('Popped ' . $i . ' from the queue, new queue size ' . $messageQueue->queueSize()); @@ -188,7 +220,7 @@ $messagingServer->heartbeat(); // Send to PUB queue - $publisher->sendmulti(["H", "", ""], \ZMQ::MODE_DONTWAIT); + $publisher?->sendmulti(["H", "", ""], \ZMQ::MODE_DONTWAIT); }); // Key management diff --git a/src/Controller/Api.php b/src/Controller/Api.php index 655e955..8aa591e 100644 --- a/src/Controller/Api.php +++ b/src/Controller/Api.php @@ -29,7 +29,8 @@ class Api { public function __construct( private readonly Queue $queue, - private readonly LoggerInterface $logger + private readonly LoggerInterface $logger, + private readonly Relay $relay, ) { } @@ -50,6 +51,11 @@ public function handleMessage(array $message): Response } else if ($type === 'keys') { // Register new keys for this CMS. $this->queue->addKey($message['id'], $message['key']); + + // Relay new keys. + if ($this->relay->isRelay()) { + $this->relay->relayArray($message); + } } else if ($type === 'multi') { $this->logger->debug('Queuing multiple messages'); foreach ($message['messages'] as $message) { diff --git a/src/Controller/Relay.php b/src/Controller/Relay.php new file mode 100644 index 0000000..59dbb58 --- /dev/null +++ b/src/Controller/Relay.php @@ -0,0 +1,128 @@ +. + */ + +namespace Xibo\Controller; + + +use GuzzleHttp\Client; +use GuzzleHttp\Exception\GuzzleException; +use Psr\Log\LoggerInterface; +use Xibo\Entity\Message; + +class Relay +{ + private readonly ?Client $client; + private ?\ZMQSocket $socket; + + public function __construct( + private readonly LoggerInterface $logger, + private readonly string $relayMessages, + private string $relayOldMessages, + ) { + // Create a client for us to use + if (!empty($this->relayMessages)) { + $this->client = new Client([ + 'base_uri' => $this->relayMessages, + ]); + } else { + $this->client = null; + } + } + + public function configureZmq(): void + { + // Create a socket for us to use. + try { + $this->socket = (new \ZMQContext())->getSocket(\ZMQ::SOCKET_REQ); + $this->socket->setSockOpt(\ZMQ::SOCKOPT_LINGER, 2000); + $this->socket->connect($this->relayOldMessages); + } catch (\Exception $exception) { + $this->socket = null; + $this->relayOldMessages = null; + + $this->logger->critical('Unable to connect to old message relay: ' + . $this->relayOldMessages . ', e = ' . $exception->getMessage()); + } + } + + public function isRelay(): bool + { + return !empty($this->relayMessages); + } + + public function isRelayOld(): bool + { + return !empty($this->relayOldMessages); + } + + /** + * Relay a message appropriately + * @param \Xibo\Entity\Message $message + * @return void + */ + public function relay(Message $message): void + { + if ($message->isWebSocket) { + $this->relayArray($message->jsonSerialize()); + } else { + try { + $this->socket->send(json_encode($message)); + } catch (\ZMQSocketException $socketException) { + $this->logger->error('relay: [' . $socketException->getCode() . '] ' . $socketException->getMessage()); + return; + } + + $retries = 15; + + do { + try { + $reply = $this->socket->recv(\ZMQ::MODE_DONTWAIT); + + if ($reply !== false) { + break; + } + } catch (\ZMQSocketException $socketException) { + $this->logger->error('relay: [' . $socketException->getCode() . '] ' . $socketException->getMessage()); + break; + } + + usleep(100000); + } while (--$retries); + } + } + + /** + * Relay array (only ever a message over private API) + * @param array $message + * @return void + */ + public function relayArray(array $message): void + { + try { + $this->client?->post('/', [ + 'json' => $message, + ]); + } catch (GuzzleException | \Exception $e) { + $this->logger->error('relayArray: Unable to relay, e = ' . $e->getMessage()); + } + } +} \ No newline at end of file diff --git a/src/Entity/Message.php b/src/Entity/Message.php index 722e8bd..8f7e26c 100644 --- a/src/Entity/Message.php +++ b/src/Entity/Message.php @@ -21,11 +21,22 @@ */ namespace Xibo\Entity; -class Message +class Message implements \JsonSerializable { public string $channel; public string $key; public string $message; public int $qos; public bool $isWebSocket; + + public function jsonSerialize(): array + { + return [ + 'channel' => $this->channel, + 'key' => $this->key, + 'message' => $this->message, + 'qos' => $this->qos, + 'isWebSocket' => $this->isWebSocket, + ]; + } } From 01b206054a7ecdc0d54b88d37d2c17c6acab1431 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 13 Jan 2025 17:21:20 +0000 Subject: [PATCH 7/9] XMR: add environment variables for configuring socket addresses and ports. --- Dockerfile | 3 +++ entrypoint.sh | 8 ++++---- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/Dockerfile b/Dockerfile index 3bd181e..67ab540 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,6 +11,9 @@ ENV XMR_QUEUE_SIZE=10 ENV XMR_IPV6PUBSUPPORT=false ENV XMR_RELAY_OLD_MESSAGES=false ENV XMR_RELAY_MESSAGES=false +ENV XMR_SOCKETS_WS=0.0.0.0:8080 +ENV XMR_SOCKETS_API=0.0.0.0:8081 +ENV XMR_SOCKETS_ZM_PORT=9505 RUN apt-get update && apt-get install -y libzmq3-dev git \ && rm -rf /var/lib/apt/lists/* diff --git a/entrypoint.sh b/entrypoint.sh index 1d7d085..412f55a 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -1,7 +1,7 @@ #!/bin/sh # -# Copyright (C) 2024 Xibo Signage Ltd +# Copyright (C) 2025 Xibo Signage Ltd # # Xibo - Digital Signage - https://xibosignage.com # @@ -24,9 +24,9 @@ # Write config.json echo '{' > /opt/xmr/config.json echo ' "sockets": {' >> /opt/xmr/config.json -echo ' "ws": "0.0.0.0:8080",' >> /opt/xmr/config.json -echo ' "api": "0.0.0.0:8081",' >> /opt/xmr/config.json -echo ' "zmq": ["tcp://*:9505"]' >> /opt/xmr/config.json +echo ' "ws": "'$XMR_SOCKETS_WS'",' >> /opt/xmr/config.json +echo ' "api": "'$XMR_SOCKETS_API'",' >> /opt/xmr/config.json +echo ' "zmq": ["tcp://*:'$XMR_SOCKETS_ZM_PORT'"]' >> /opt/xmr/config.json echo ' },' >> /opt/xmr/config.json echo ' "queuePoll": '$XMR_QUEUE_POLL',' >> /opt/xmr/config.json echo ' "queueSize": '$XMR_QUEUE_SIZE',' >> /opt/xmr/config.json From 6ed3ecc70d17f8cfa2214b9b1bd9fe414e55e787 Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 13 Jan 2025 17:42:12 +0000 Subject: [PATCH 8/9] XMR: fix relayOldMessages setting. --- entrypoint.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/entrypoint.sh b/entrypoint.sh index 412f55a..743ada5 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -32,7 +32,7 @@ echo ' "queuePoll": '$XMR_QUEUE_POLL',' >> /opt/xmr/config.json echo ' "queueSize": '$XMR_QUEUE_SIZE',' >> /opt/xmr/config.json echo ' "debug": '$XMR_DEBUG',' >> /opt/xmr/config.json echo ' "ipv6PubSupport": '$XMR_IPV6PUBSUPPORT',' >> /opt/xmr/config.json -echo ' "relayOldMessages": '$XMR_RELAY_OLD_MESSAGES',' >> /opt/xmr/config.json +echo ' "relayOldMessages": "'$XMR_RELAY_OLD_MESSAGES'",' >> /opt/xmr/config.json echo ' "relayMessages": '$XMR_RELAY_MESSAGES >> /opt/xmr/config.json echo '}' >> /opt/xmr/config.json From e48d4c876805879f081c47239d0060b6e89e369e Mon Sep 17 00:00:00 2001 From: Dan Garner Date: Mon, 13 Jan 2025 17:55:07 +0000 Subject: [PATCH 9/9] XMR: fix relayMessages setting. --- entrypoint.sh | 2 +- src/Controller/Relay.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/entrypoint.sh b/entrypoint.sh index 743ada5..23db7b2 100755 --- a/entrypoint.sh +++ b/entrypoint.sh @@ -33,7 +33,7 @@ echo ' "queueSize": '$XMR_QUEUE_SIZE',' >> /opt/xmr/config.json echo ' "debug": '$XMR_DEBUG',' >> /opt/xmr/config.json echo ' "ipv6PubSupport": '$XMR_IPV6PUBSUPPORT',' >> /opt/xmr/config.json echo ' "relayOldMessages": "'$XMR_RELAY_OLD_MESSAGES'",' >> /opt/xmr/config.json -echo ' "relayMessages": '$XMR_RELAY_MESSAGES >> /opt/xmr/config.json +echo ' "relayMessages": "'$XMR_RELAY_MESSAGES'"' >> /opt/xmr/config.json echo '}' >> /opt/xmr/config.json /usr/local/bin/php /opt/xmr/index.php \ No newline at end of file diff --git a/src/Controller/Relay.php b/src/Controller/Relay.php index 59dbb58..9350cea 100644 --- a/src/Controller/Relay.php +++ b/src/Controller/Relay.php @@ -1,6 +1,6 @@ relayMessages)) { + if (!empty($this->relayMessages) && $this->relayMessages !== 'false') { $this->client = new Client([ 'base_uri' => $this->relayMessages, ]); @@ -66,12 +66,12 @@ public function configureZmq(): void public function isRelay(): bool { - return !empty($this->relayMessages); + return !empty($this->relayMessages) && $this->relayMessages !== 'false'; } public function isRelayOld(): bool { - return !empty($this->relayOldMessages); + return !empty($this->relayOldMessages) && $this->relayOldMessages !== 'false'; } /**