From de8f9ac48d64b5f587b8cb125103adbdc79f108f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 10:17:34 +0100 Subject: [PATCH 01/79] Integrate websocket support --- src/main/php/web/Application.class.php | 7 +- src/main/php/web/handler/WebSocket.class.php | 48 ++++++++ .../php/xp/web/srv/HttpProtocol.class.php | 64 ++-------- src/main/php/xp/web/srv/Protocol.class.php | 110 ++++++++++++++++++ src/main/php/xp/web/srv/Standalone.class.php | 5 +- src/main/php/xp/web/srv/Switchable.class.php | 51 ++++++++ src/main/php/xp/web/srv/WsProtocol.class.php | 94 +++++++++++++++ 7 files changed, 320 insertions(+), 59 deletions(-) create mode 100755 src/main/php/web/handler/WebSocket.class.php create mode 100755 src/main/php/xp/web/srv/Protocol.class.php create mode 100755 src/main/php/xp/web/srv/Switchable.class.php create mode 100755 src/main/php/xp/web/srv/WsProtocol.class.php diff --git a/src/main/php/web/Application.class.php b/src/main/php/web/Application.class.php index c926188a..20b22c57 100755 --- a/src/main/php/web/Application.class.php +++ b/src/main/php/web/Application.class.php @@ -79,7 +79,7 @@ public function install($arg) { * * @param web.Request $request * @param web.Response $response - * @return var + * @return iterable */ public function service($request, $response) { $seen= []; @@ -95,7 +95,12 @@ public function service($request, $response) { throw new Error(508, 'Internal redirect loop caused by dispatch to '.$argument); } goto dispatch; + } else if ('connection' === $kind) { + $response->header('Connection', 'upgrade'); + $response->header('Upgrade', $argument[0]); + return $argument; } + yield $kind => $argument; } } diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php new file mode 100755 index 00000000..3f5ed505 --- /dev/null +++ b/src/main/php/web/handler/WebSocket.class.php @@ -0,0 +1,48 @@ +listener= Listeners::cast($listener); + } + + /** + * Handles a request + * + * @param web.Request $request + * @param web.Response $response + * @return var + */ + public function handle($request, $response) { + switch ($version= (int)$request->header('Sec-WebSocket-Version')) { + case 13: + $key= $request->header('Sec-WebSocket-Key'); + $response->answer(101); + $response->header('Sec-WebSocket-Accept', base64_encode(sha1($key.self::GUID, true))); + foreach ($this->listener->protocols ?? [] as $protocol) { + $response->header('Sec-WebSocket-Protocol', $protocol, true); + } + break; + + case 0: + $response->answer(426); + $response->send('This service requires use of the WebSocket protocol', 'text/plain'); + return; + + default: + $response->answer(400); + $response->send('This service does not support WebSocket version '.$version, 'text/plain'); + return; + } + + yield 'connection' => ['websocket', ['request' => $request, 'listener' => $this->listener]]; + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index 5de7199b..af4e5e45 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -2,7 +2,6 @@ use Throwable; use lang\ClassLoader; -use peer\server\{AsyncServer, ServerProtocol}; use web\{Error, InternalServerError, Request, Response, Headers, Status}; /** @@ -10,7 +9,7 @@ * * @test xp://web.unittest.HttpProtocolTest */ -class HttpProtocol implements ServerProtocol { +class HttpProtocol extends Switchable { private $application, $logging; public $server= null; private $close= false; @@ -21,34 +20,11 @@ class HttpProtocol implements ServerProtocol { * @param web.Application $application * @param web.Logging $logging */ - private function __construct($application, $logging) { + public function __construct($application, $logging) { $this->application= $application; $this->logging= $logging; } - /** - * Creates an instance of HTTP protocol executing the given application - * - * @param web.Application $application - * @param web.Logging $logging - * @return self - */ - public static function executing($application, $logging) { - - // Compatibility with older xp-framework/networking libraries, see issue #79 - // Unwind generators returned from handleData() to guarantee their complete - // execution. - if (class_exists(AsyncServer::class, true)) { - return new self($application, $logging); - } else { - return new class($application, $logging) extends HttpProtocol { - public function handleData($socket) { - foreach (parent::handleData($socket) as $_) { } - } - }; - } - } - /** * Sends an error * @@ -90,24 +66,6 @@ public function initialize() { return true; } - /** - * Handle client connect - * - * @param peer.Socket $socket - */ - public function handleConnect($socket) { - // Intentionally empty - } - - /** - * Handle client disconnect - * - * @param peer.Socket $socket - */ - public function handleDisconnect($socket) { - $socket->close(); - } - /** * Handle client data * @@ -140,8 +98,11 @@ public function handleData($socket) { } try { + $result= null; if (Input::REQUEST === $input->kind) { - yield from $this->application->service($request, $response) ?? []; + $handler= $this->application->service($request, $response); + yield from $handler; + $result= $handler->getReturn(); } else if ($input->kind & Input::TIMEOUT) { $response->answer(408); $response->send('Client timed out sending status line and request headers', 'text/plain'); @@ -168,7 +129,7 @@ public function handleData($socket) { clearstatcache(); \xp::gc(); } - return; + return $result; } // Handle request errors and close the socket @@ -184,15 +145,4 @@ public function handleData($socket) { } $socket->close(); } - - /** - * Handle I/O error - * - * @param peer.Socket $socket - * @param lang.XPException $e - */ - public function handleError($socket, $e) { - // $e->printStackTrace(); - $socket->close(); - } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php new file mode 100755 index 00000000..210f3046 --- /dev/null +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -0,0 +1,110 @@ +protocols[$protocol]= $delegate; + return $this; + } + + /** + * Initialize protocol + * + * @return bool + */ + public function initialize() { + foreach ($this->protocols as $protocol) { + $protocol->initialize(); + } + return true; + } + + /** + * Handle client connect + * + * @param peer.Socket $socket + */ + public function handleConnect($socket) { + $this->protocols[spl_object_id($socket)]= current($this->protocols); + } + + /** + * Handle client data + * + * @param peer.Socket $socket + * @return void + */ + public function handleData($socket) { + $handle= spl_object_id($socket); + $handler= $this->protocols[$handle]->handleData($socket); + + if ($handler instanceof Generator) { + yield from $handler; + + if ($switch= $handler->getReturn()) { + list($protocol, $context)= $switch; + + $this->protocols[$handle]= $this->protocols[$protocol]; + $this->protocols[$handle]->handleSwitch($socket, $context); + } + } + } + + /** + * Handle client disconnect + * + * @param peer.Socket $socket + */ + public function handleDisconnect($socket) { + $handle= spl_object_id($socket); + $this->protocols[$handle]->handleDisconnect($socket); + + unset($this->protocols[$handle]); + $socket->close(); + } + + /** + * Handle I/O error + * + * @param peer.Socket $socket + * @param lang.XPException $e + */ + public function handleError($socket, $e) { + $handle= spl_object_id($socket); + $this->protocols[$handle]->handleError($socket, $e); + + unset($this->protocols[$handle]); + $socket->close(); + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Standalone.class.php b/src/main/php/xp/web/srv/Standalone.class.php index 8d5a2349..2321498d 100755 --- a/src/main/php/xp/web/srv/Standalone.class.php +++ b/src/main/php/xp/web/srv/Standalone.class.php @@ -60,7 +60,10 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $application->routing(); $socket= new ServerSocket($this->host, $this->port); - $this->impl->listen($socket, HttpProtocol::executing($application, $environment->logging())); + $this->impl->listen($socket, Protocol::multiplex() + ->serving('http', new HttpProtocol($application, $environment->logging())) + ->serving('websocket', new WsProtocol($environment->logging())) + ); $this->impl->init(); Console::writeLine("\e[33m@", nameof($this), '(HTTP @ ', $socket->toString(), ")\e[0m"); diff --git a/src/main/php/xp/web/srv/Switchable.class.php b/src/main/php/xp/web/srv/Switchable.class.php new file mode 100755 index 00000000..e8628c0d --- /dev/null +++ b/src/main/php/xp/web/srv/Switchable.class.php @@ -0,0 +1,51 @@ +handleSwitch($socket, null); + } + + /** + * Handle client switch + * + * @param peer.Socket $socket + * @param var $context + */ + public function handleSwitch($socket, $context) { + // NOOP + } + + /** + * Handle client disconnect + * + * @param peer.Socket $socket + */ + public function handleDisconnect($socket) { + // NOOP + } + + /** + * Handle I/O error + * + * @param peer.Socket $socket + * @param lang.XPException $e + */ + public function handleError($socket, $e) { + // NOOP + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WsProtocol.class.php new file mode 100755 index 00000000..aece99bc --- /dev/null +++ b/src/main/php/xp/web/srv/WsProtocol.class.php @@ -0,0 +1,94 @@ +logging= $logging; + } + + /** + * Handle client switch + * + * @param peer.Socket $socket + * @param var $context + */ + public function handleSwitch($socket, $context) { + $socket->setTimeout(600); + $socket->useNoDelay(); + + $id= spl_object_id($socket); + $this->connections[$id]= new Connection( + $socket, + $id, + $context['listener'], + $context['request']->uri()->path(), + $context['request']->headers() + ); + $this->connections[$id]->open(); + } + + /** + * Handle client data + * + * @param peer.Socket $socket + * @return void + */ + public function handleData($socket) { + $conn= $this->connections[spl_object_id($socket)]; + foreach ($conn->receive() as $type => $message) { + try { + if (Opcodes::CLOSE === $type) { + $conn->close(); + $hint= 'status='.unpack('n', $message)[1]; + } else { + yield from $conn->on($message) ?? []; + $hint= ''; + } + } catch (Any $e) { + $hint= Throwable::wrap($e)->compoundMessage(); + } + + // TODO: Use logging facility + // $this->logging->log($request, $response); + \util\cmd\Console::writeLinef( + " \e[33m[%s %d %.3fkB]\e[0m WS %s %s%s", + date('Y-m-d H:i:s'), + getmypid(), + memory_get_usage() / 1024, + Opcodes::nameOf($type), + $conn->path(), + $hint ? " \e[2m[{$hint}]\e[0m" : '' + ); + } + } + + /** + * Handle client disconnect + * + * @param peer.Socket $socket + */ + public function handleDisconnect($socket) { + unset($this->connections[spl_object_id($socket)]); + } + + /** + * Handle I/O error + * + * @param peer.Socket $socket + * @param lang.XPException $e + */ + public function handleError($socket, $e) { + unset($this->connections[spl_object_id($socket)]); + } +} \ No newline at end of file From 0a3bee09c072bf204c83b2e269c14025448dd91e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 10:31:42 +0100 Subject: [PATCH 02/79] Add dependency on xp-forge/websockets --- composer.json | 1 + 1 file changed, 1 insertion(+) diff --git a/composer.json b/composer.json index 43a59111..fb87a709 100755 --- a/composer.json +++ b/composer.json @@ -9,6 +9,7 @@ "xp-framework/core": "^12.0 | ^11.0 | ^10.0", "xp-framework/networking": "^10.1 | ^9.3", "xp-forge/uri": "^3.0 | ^2.0 | ^1.4", + "xp-forge/websockets": "^4.0 | ^3.1", "php": ">=7.0.0" }, "require-dev" : { From b061faef8867820f1722221b0dda9c1df3377861 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 10:40:27 +0100 Subject: [PATCH 03/79] Fix HTTP protocol test --- .../web/unittest/HttpProtocolTest.class.php | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/test/php/web/unittest/HttpProtocolTest.class.php b/src/test/php/web/unittest/HttpProtocolTest.class.php index 773f6653..a0879f89 100755 --- a/src/test/php/web/unittest/HttpProtocolTest.class.php +++ b/src/test/php/web/unittest/HttpProtocolTest.class.php @@ -56,12 +56,12 @@ private function handle($p, $in) { #[Test] public function can_create() { - HttpProtocol::executing($this->application(function($req, $res) { }), $this->log); + new HttpProtocol($this->application(function($req, $res) { }), $this->log); } #[Test] public function default_headers() { - $p= HttpProtocol::executing($this->application(function($req, $res) { }), $this->log); + $p= new HttpProtocol($this->application(function($req, $res) { }), $this->log); $this->assertHttp( "HTTP/1.1 200 OK\r\n". "Date: [A-Za-z]+, [0-9]+ [A-Za-z]+ [0-9]+ [0-9]+:[0-9]+:[0-9]+ GMT\r\n". @@ -74,7 +74,7 @@ public function default_headers() { #[Test] public function connection_close_is_honoured() { - $p= HttpProtocol::executing($this->application(function($req, $res) { }), $this->log); + $p= new HttpProtocol($this->application(function($req, $res) { }), $this->log); $this->assertHttp( "HTTP/1.1 200 OK\r\n". "Date: [A-Za-z]+, [0-9]+ [A-Za-z]+ [0-9]+ [0-9]+:[0-9]+:[0-9]+ GMT\r\n". @@ -88,7 +88,7 @@ public function connection_close_is_honoured() { #[Test] public function responds_with_http_10_for_http_10_requests() { - $p= HttpProtocol::executing($this->application(function($req, $res) { }), $this->log); + $p= new HttpProtocol($this->application(function($req, $res) { }), $this->log); $this->assertHttp( "HTTP/1.0 200 OK\r\n". "Date: [A-Za-z]+, [0-9]+ [A-Za-z]+ [0-9]+ [0-9]+:[0-9]+:[0-9]+ GMT\r\n". @@ -102,7 +102,7 @@ public function responds_with_http_10_for_http_10_requests() { #[Test] public function handles_chunked_transfer_input() { $echo= function($req, $res) { $res->send(Streams::readAll($req->stream()), 'text/plain'); }; - $p= HttpProtocol::executing($this->application($echo), $this->log); + $p= new HttpProtocol($this->application($echo), $this->log); $this->assertHttp( "HTTP/1.1 200 OK\r\n". "Date: [A-Za-z]+, [0-9]+ [A-Za-z]+ [0-9]+ [0-9]+:[0-9]+:[0-9]+ GMT\r\n". @@ -120,7 +120,7 @@ public function handles_chunked_transfer_input() { #[Test] public function buffers_and_sets_content_length_for_http10() { $echo= function($req, $res) { with ($res->stream(), function($s) { $s->write('Test'); }); }; - $p= HttpProtocol::executing($this->application($echo), $this->log); + $p= new HttpProtocol($this->application($echo), $this->log); $this->assertHttp( "HTTP/1.0 200 OK\r\n". "Date: [A-Za-z]+, [0-9]+ [A-Za-z]+ [0-9]+ [0-9]+:[0-9]+:[0-9]+ GMT\r\n". @@ -134,7 +134,7 @@ public function buffers_and_sets_content_length_for_http10() { #[Test] public function produces_chunked_transfer_output_for_http11() { $echo= function($req, $res) { with ($res->stream(), function($s) { $s->write('Test'); }); }; - $p= HttpProtocol::executing($this->application($echo), $this->log); + $p= new HttpProtocol($this->application($echo), $this->log); $this->assertHttp( "HTTP/1.1 200 OK\r\n". "Date: [A-Za-z]+, [0-9]+ [A-Za-z]+ [0-9]+ [0-9]+:[0-9]+:[0-9]+ GMT\r\n". @@ -148,7 +148,7 @@ public function produces_chunked_transfer_output_for_http11() { #[Test] public function catches_write_errors_and_logs_them_as_warning() { $caught= null; - $p= HttpProtocol::executing( + $p= new HttpProtocol( $this->application(function($req, $res) { with ($res->stream(), function($s) { $s->write('Test'); @@ -173,7 +173,7 @@ public function catches_write_errors_and_logs_them_as_warning() { #[Test] public function response_trace_appears_in_log() { $logged= null; - $p= HttpProtocol::executing( + $p= new HttpProtocol( $this->application(function($req, $res) { $res->trace('request-time-ms', 1); }), From 2fd7308cb93192040f00afe09c09350a5c1a7ed8 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 10:43:05 +0100 Subject: [PATCH 04/79] Fix integration test --- src/it/php/web/unittest/TestingServer.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/it/php/web/unittest/TestingServer.class.php b/src/it/php/web/unittest/TestingServer.class.php index 04b1a595..479522b1 100755 --- a/src/it/php/web/unittest/TestingServer.class.php +++ b/src/it/php/web/unittest/TestingServer.class.php @@ -26,7 +26,7 @@ public static function main(array $args) { $s= new AsyncServer(); try { - $s->listen($socket, HttpProtocol::executing($application, new Logging(null))); + $s->listen($socket, new HttpProtocol($application, new Logging(null))); $s->init(); Console::writeLinef('+ Service %s:%d', $socket->host, $socket->port); $s->service(); From 8d54e89d3747c8407ba9447f1b20c49fc7f88bb1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 10:58:20 +0100 Subject: [PATCH 05/79] Add test for WebSocket routing handler --- src/main/php/web/handler/WebSocket.class.php | 7 ++- .../unittest/handler/WebSocketTest.class.php | 43 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100755 src/test/php/web/unittest/handler/WebSocketTest.class.php diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index 3f5ed505..7c94ac4c 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -3,7 +3,12 @@ use web\Handler; use websocket\Listeners; -/** @see https://www.rfc-editor.org/rfc/rfc6455 */ +/** + * WebSocket handler used for routing websocket handshake requests + * + * @test web.unittest.handler.WebSocketTest + * @see https://www.rfc-editor.org/rfc/rfc6455 + */ class WebSocket implements Handler { const GUID= '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'; diff --git a/src/test/php/web/unittest/handler/WebSocketTest.class.php b/src/test/php/web/unittest/handler/WebSocketTest.class.php new file mode 100755 index 00000000..29400d5a --- /dev/null +++ b/src/test/php/web/unittest/handler/WebSocketTest.class.php @@ -0,0 +1,43 @@ +handle($request, $response)->next(); + return $response; + } + + #[Test] + public function can_create() { + new WebSocket(function($conn, $payload) { }); + } + + #[Test] + public function switching_protocols() { + $response= $this->handle(new Request(new TestInput('GET', '/ws', [ + 'Sec-WebSocket-Version' => 13, + 'Sec-WebSocket-Key' => 'test', + ]))); + Assert::equals(101, $response->status()); + Assert::equals('tNpbgC8ZQDOcSkHAWopKzQjJ1hI=', $response->headers()['Sec-WebSocket-Accept']); + } + + #[Test] + public function non_websocket_request() { + $response= $this->handle(new Request(new TestInput('GET', '/ws'))); + Assert::equals(426, $response->status()); + } + + #[Test] + public function unsupported_websocket_version() { + $response= $this->handle(new Request(new TestInput('GET', '/ws', ['Sec-WebSocket-Version' => 11]))); + Assert::equals(400, $response->status()); + } +} \ No newline at end of file From f6ce581c4f78a68aed1ca62e5f1458ff87e96bf8 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 12:49:21 +0100 Subject: [PATCH 06/79] Bump dependency on xp-forge/websockets to 4.1+ This also removes support for PHP versions < 7.4 as the websockets library requires 7.4+ in its 4.0-SERIES. See https://github.com/xp-framework/rfc/issues/343 and https://github.com/xp-forge/web/pull/121#issuecomment-2585698223 --- .github/workflows/ci.yml | 2 +- ChangeLog.md | 5 +++++ README.md | 2 +- composer.json | 8 ++++---- 4 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2af28f68..1c54ab2e 100755 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,7 +15,7 @@ jobs: strategy: fail-fast: false matrix: - php-versions: ['7.0', '7.1', '7.2', '7.3', '7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] + php-versions: ['7.4', '8.0', '8.1', '8.2', '8.3', '8.4'] os: [ubuntu-latest, windows-latest] steps: diff --git a/ChangeLog.md b/ChangeLog.md index 57b11d27..bfbeb372 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -3,6 +3,11 @@ Web change log ## ?.?.? / ????-??-?? +## 5.0.0 / ????-??-?? + +* **Heads up:** Dropped support for PHP < 7.4, see xp-framework/rfc#343 + (@thekid) + ## 4.5.2 / 2025-01-05 * Fixed server to write warnings when not being able to send HTTP headers diff --git a/README.md b/README.md index ca938589..243a0ef9 100755 --- a/README.md +++ b/README.md @@ -4,7 +4,7 @@ Web applications for the XP Framework [![Build status on GitHub](https://github.com/xp-forge/web/workflows/Tests/badge.svg)](https://github.com/xp-forge/web/actions) [![XP Framework Module](https://raw.githubusercontent.com/xp-framework/web/master/static/xp-framework-badge.png)](https://github.com/xp-framework/core) [![BSD Licence](https://raw.githubusercontent.com/xp-framework/web/master/static/licence-bsd.png)](https://github.com/xp-framework/core/blob/master/LICENCE.md) -[![Requires PHP 7.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-7_0plus.svg)](http://php.net/) +[![Requires PHP 7.4+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-7_4plus.svg)](http://php.net/) [![Supports PHP 8.0+](https://raw.githubusercontent.com/xp-framework/web/master/static/php-8_0plus.svg)](http://php.net/) [![Latest Stable Version](https://poser.pugx.org/xp-forge/web/version.svg)](https://packagist.org/packages/xp-forge/web) diff --git a/composer.json b/composer.json index fb87a709..fee7a849 100755 --- a/composer.json +++ b/composer.json @@ -7,10 +7,10 @@ "keywords": ["module", "xp"], "require" : { "xp-framework/core": "^12.0 | ^11.0 | ^10.0", - "xp-framework/networking": "^10.1 | ^9.3", - "xp-forge/uri": "^3.0 | ^2.0 | ^1.4", - "xp-forge/websockets": "^4.0 | ^3.1", - "php": ">=7.0.0" + "xp-framework/networking": "^10.1", + "xp-forge/uri": "^3.0", + "xp-forge/websockets": "^4.1", + "php": ">=7.4.0" }, "require-dev" : { "xp-framework/test": "^2.0 | ^1.0" From 260aca2d6d703a46cd37c3e4ca31d9d76c35bab6 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 12:51:06 +0100 Subject: [PATCH 07/79] Implement integration tests for websockets --- src/it/php/web/unittest/IntegrationTest.class.php | 14 ++++++++++++++ .../php/web/unittest/TestingApplication.class.php | 4 ++++ src/it/php/web/unittest/TestingServer.class.php | 8 ++++++-- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/src/it/php/web/unittest/IntegrationTest.class.php b/src/it/php/web/unittest/IntegrationTest.class.php index a5469329..678dca08 100755 --- a/src/it/php/web/unittest/IntegrationTest.class.php +++ b/src/it/php/web/unittest/IntegrationTest.class.php @@ -1,6 +1,7 @@ send('GET', '/cookie', '1.0', ['Cookie' => $header]); Assert::equals((string)strlen($header), $r['body']); } + + #[Test] + public function websocket_message() { + try { + $ws= new WebSocket($this->server->connection, '/ws'); + $ws->connect(); + $ws->send('Test'); + $echo= $ws->receive(); + } finally { + $ws->close(); + } + Assert::equals('Echo: Test', $echo); + } } \ No newline at end of file diff --git a/src/it/php/web/unittest/TestingApplication.class.php b/src/it/php/web/unittest/TestingApplication.class.php index db69eae6..4c9d7676 100755 --- a/src/it/php/web/unittest/TestingApplication.class.php +++ b/src/it/php/web/unittest/TestingApplication.class.php @@ -2,6 +2,7 @@ use lang\XPClass; use test\Assert; +use web\handler\WebSocket; use web\{Application, Error}; class TestingApplication extends Application { @@ -9,6 +10,9 @@ class TestingApplication extends Application { /** @return var */ public function routes() { return [ + '/ws' => new WebSocket(function($conn, $payload) { + $conn->send('Echo: '.$payload); + }), '/status/420' => function($req, $res) { $res->answer(420, $req->param('message') ?? 'Enhance your calm'); $res->send('Answered with status 420', 'text/plain'); diff --git a/src/it/php/web/unittest/TestingServer.class.php b/src/it/php/web/unittest/TestingServer.class.php index 479522b1..a0b31ad6 100755 --- a/src/it/php/web/unittest/TestingServer.class.php +++ b/src/it/php/web/unittest/TestingServer.class.php @@ -5,7 +5,7 @@ use peer\server\AsyncServer; use util\cmd\Console; use web\{Environment, Logging}; -use xp\web\srv\HttpProtocol; +use xp\web\srv\{Protocol, HttpProtocol, WsProtocol}; /** * Socket server used by integration tests. @@ -23,10 +23,14 @@ class TestingServer { public static function main(array $args) { $application= new TestingApplication(new Environment('test', '.', '.', '.', [], null)); $socket= new ServerSocket('127.0.0.1', $args[0] ?? 0); + $log= new Logging(null); $s= new AsyncServer(); try { - $s->listen($socket, new HttpProtocol($application, new Logging(null))); + $s->listen($socket, Protocol::multiplex() + ->serving('http', new HttpProtocol($application, $log)) + ->serving('websocket', new WsProtocol($log)) + ); $s->init(); Console::writeLinef('+ Service %s:%d', $socket->host, $socket->port); $s->service(); From ee8fa876d2c41b9bb05de9a38a2f6228ca7b7190 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 18:50:27 +0100 Subject: [PATCH 08/79] Remove PHP 7.0 and 7.1 compatibility --- src/main/php/xp/web/srv/Protocol.class.php | 8 -------- 1 file changed, 8 deletions(-) diff --git a/src/main/php/xp/web/srv/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php index 210f3046..3f982a6c 100755 --- a/src/main/php/xp/web/srv/Protocol.class.php +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -7,14 +7,6 @@ class Protocol implements ServerProtocol { private $protocols= []; - static function __static() { - - // PHP 7.0 and 7.1 compatibility - if (!function_exists('spl_object_id')) { - function spl_object_id($object) { return spl_object_hash($object); } - } - } - /** Creates a new instance of this multiplex protocol */ public static function multiplex(): self { From 274bc84cdce96bdc6da2efaf23ce30fafeb939ed Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 19:35:23 +0100 Subject: [PATCH 09/79] Refactor logging API --- src/main/php/web/Logging.class.php | 25 +++++++-- src/main/php/web/logging/Sink.class.php | 7 +-- src/main/php/web/logging/ToAllOf.class.php | 13 ++--- src/main/php/web/logging/ToCategory.class.php | 14 +++-- src/main/php/web/logging/ToConsole.class.php | 16 +++--- src/main/php/web/logging/ToFile.class.php | 16 +++--- src/main/php/web/logging/ToFunction.class.php | 11 ++-- .../php/xp/web/srv/HttpProtocol.class.php | 6 +-- src/main/php/xp/web/srv/WsProtocol.class.php | 18 ++----- .../web/unittest/HttpProtocolTest.class.php | 9 ++-- .../php/web/unittest/LoggingTest.class.php | 53 ++++++++++++------- .../web/unittest/logging/SinkTest.class.php | 2 +- .../unittest/logging/ToAllOfTest.class.php | 30 +++++------ .../unittest/logging/ToCategoryTest.class.php | 16 ++---- .../unittest/logging/ToConsoleTest.class.php | 8 +-- .../web/unittest/logging/ToFileTest.class.php | 15 ++---- 16 files changed, 132 insertions(+), 127 deletions(-) diff --git a/src/main/php/web/Logging.class.php b/src/main/php/web/Logging.class.php index 803c57a5..4ad3a18b 100755 --- a/src/main/php/web/Logging.class.php +++ b/src/main/php/web/Logging.class.php @@ -58,15 +58,34 @@ public function tee($sink) { } /** - * Writes a log entry + * Writes a HTTP exchange to the log * * @param web.Request $response * @param web.Response $response * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints= []) { - $this->sink && $this->sink->log($request, $response, $hints); + public function exchange($request, $response, $hints= []) { + if (!$this->sink) return; + + $uri= $request->uri()->path(); + if ($query= $request->uri()->query()) { + $uri.= '?'.$query; + } + $this->sink->log($response->status(), $request->method(), $uri, $response->trace + $hints); + } + + /** + * Writes a log entry + * + * @param string $status + * @param string $method + * @param string $uri + * @param [:var] $hints Optional hints + * @return void + */ + public function log($status, $method, $uri, $hints= []) { + $this->sink && $this->sink->log($status, $method, $uri, $hints); } /** diff --git a/src/main/php/web/logging/Sink.class.php b/src/main/php/web/logging/Sink.class.php index 9a22283f..9f925f0b 100755 --- a/src/main/php/web/logging/Sink.class.php +++ b/src/main/php/web/logging/Sink.class.php @@ -12,12 +12,13 @@ abstract class Sink { /** * Writes a log entry * - * @param web.Request $response - * @param web.Response $response + * @param string $status + * @param string $method + * @param string $uri * @param [:var] $hints Optional hints * @return void */ - public abstract function log($request, $response, $hints); + public abstract function log($status, $method, $uri, $hints); /** @return string */ public function target() { return nameof($this); } diff --git a/src/main/php/web/logging/ToAllOf.class.php b/src/main/php/web/logging/ToAllOf.class.php index 23f768c5..3487a22f 100755 --- a/src/main/php/web/logging/ToAllOf.class.php +++ b/src/main/php/web/logging/ToAllOf.class.php @@ -3,7 +3,7 @@ /** * Log sink which logs to all given sinks * - * @test xp://web.unittest.logging.ToAllOfTest + * @test web.unittest.logging.ToAllOfTest */ class ToAllOf extends Sink { private $sinks= []; @@ -11,7 +11,7 @@ class ToAllOf extends Sink { /** * Creates a sink writing to all given other sinks * - * @param (web.log.Sink|util.log.LogCategory|function(web.Request, web.Response, string): void)... $arg + * @param (web.log.Sink|util.log.LogCategory|function(string, string, string, [:var]): void)... $arg */ public function __construct(... $args) { foreach ($args as $arg) { @@ -40,14 +40,15 @@ public function target() { /** * Writes a log entry * - * @param web.Request $response - * @param web.Response $response + * @param string $status + * @param string $method + * @param string $uri * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { + public function log($status, $method, $uri, $hints) { foreach ($this->sinks as $sink) { - $sink->log($request, $response, $hints); + $sink->log($status, $method, $uri, $hints); } } } \ No newline at end of file diff --git a/src/main/php/web/logging/ToCategory.class.php b/src/main/php/web/logging/ToCategory.class.php index 9d5aa134..e99a4243 100755 --- a/src/main/php/web/logging/ToCategory.class.php +++ b/src/main/php/web/logging/ToCategory.class.php @@ -14,19 +14,17 @@ public function target() { return nameof($this).'('.$this->cat->toString().')'; /** * Writes a log entry * - * @param web.Request $response - * @param web.Response $response + * @param string $status + * @param string $method + * @param string $uri * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $query= $request->uri()->query(); - $uri= $request->uri()->path().($query ? '?'.$query : ''); - + public function log($status, $method, $uri, $hints) { if ($hints) { - $this->cat->warn($response->status(), $request->method(), $uri, $hints); + $this->cat->warn($status, $method, $uri, $hints); } else { - $this->cat->info($response->status(), $request->method(), $uri); + $this->cat->info($status, $method, $uri); } } } \ No newline at end of file diff --git a/src/main/php/web/logging/ToConsole.class.php b/src/main/php/web/logging/ToConsole.class.php index cc98145f..8ea3e20d 100755 --- a/src/main/php/web/logging/ToConsole.class.php +++ b/src/main/php/web/logging/ToConsole.class.php @@ -8,26 +8,26 @@ class ToConsole extends Sink { /** * Writes a log entry * - * @param web.Request $response - * @param web.Response $response + * @param string $status + * @param string $method + * @param string $uri * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $query= $request->uri()->query(); + public function log($status, $method, $uri, $hints) { $hint= ''; foreach ($hints as $kind => $value) { $hint.= ', '.$kind.': '.(is_string($value) ? $value : Objects::stringOf($value)); } Console::writeLinef( - " \e[33m[%s %d %.3fkB]\e[0m %d %s %s%s", + " \e[33m[%s %d %.3fkB]\e[0m %s %s %s%s", date('Y-m-d H:i:s'), getmypid(), memory_get_usage() / 1024, - $response->status(), - $request->method(), - $request->uri()->path().($query ? '?'.$query : ''), + $status, + $method, + $uri, $hint ? " \e[2m[".substr($hint, 2)."]\e[0m" : '' ); } diff --git a/src/main/php/web/logging/ToFile.class.php b/src/main/php/web/logging/ToFile.class.php index 5d38d40f..4c59b274 100755 --- a/src/main/php/web/logging/ToFile.class.php +++ b/src/main/php/web/logging/ToFile.class.php @@ -28,26 +28,26 @@ public function target() { return nameof($this).'('.$this->file.')'; } /** * Writes a log entry * - * @param web.Request $response - * @param web.Response $response + * @param string $status + * @param string $method + * @param string $uri * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $query= $request->uri()->query(); + public function log($status, $method, $uri, $hints) { $hint= ''; foreach ($hints as $kind => $value) { $hint.= ', '.$kind.': '.(is_string($value) ? $value : Objects::stringOf($value)); } $line= sprintf( - "[%s %d %.3fkB] %d %s %s%s\n", + "[%s %d %.3fkB] %s %s %s%s\n", date('Y-m-d H:i:s'), getmypid(), memory_get_usage() / 1024, - $response->status(), - $request->method(), - $request->uri()->path().($query ? '?'.$query : ''), + $status, + $method, + $uri, $hint ? ' ['.substr($hint, 2).']' : '' ); file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX); diff --git a/src/main/php/web/logging/ToFunction.class.php b/src/main/php/web/logging/ToFunction.class.php index 86e70601..1f41c1b9 100755 --- a/src/main/php/web/logging/ToFunction.class.php +++ b/src/main/php/web/logging/ToFunction.class.php @@ -5,18 +5,19 @@ class ToFunction extends Sink { /** @param callable $function */ public function __construct($function) { - $this->function= cast($function, 'function(web.Request, web.Response, [:var]): void'); + $this->function= cast($function, 'function(string, string, string, [:var]): void'); } /** * Writes a log entry * - * @param web.Request $response - * @param web.Response $response + * @param string $status + * @param string $method + * @param string $uri * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $this->function->__invoke($request, $response, $hints); + public function log($status, $method, $uri, $hints) { + $this->function->__invoke($status, $method, $uri, $hints); } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index af4e5e45..e1856f5c 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -53,7 +53,7 @@ private function sendError($request, $response, $error) { break; } } - $this->logging->log($request, $response, $response->trace + ['error' => $error]); + $this->logging->exchange($request, $response, ['error' => $error]); } /** @@ -113,9 +113,9 @@ public function handleData($socket) { $close= true; } - $this->logging->log($request, $response, $response->trace); + $this->logging->exchange($request, $response); } catch (CannotWrite $e) { - $this->logging->log($request, $response, $response->trace + ['warn' => $e]); + $this->logging->exchange($request, $response, ['warn' => $e]); } catch (Error $e) { $this->sendError($request, $response, $e); } catch (Throwable $e) { diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WsProtocol.class.php index aece99bc..5b649da0 100755 --- a/src/main/php/xp/web/srv/WsProtocol.class.php +++ b/src/main/php/xp/web/srv/WsProtocol.class.php @@ -50,26 +50,16 @@ public function handleData($socket) { try { if (Opcodes::CLOSE === $type) { $conn->close(); - $hint= 'status='.unpack('n', $message)[1]; + $hints= unpack('nstatus/a*reason', $message); } else { yield from $conn->on($message) ?? []; - $hint= ''; + $hints= []; } } catch (Any $e) { - $hint= Throwable::wrap($e)->compoundMessage(); + $hint= ['error' => Throwable::wrap($e)]; } - // TODO: Use logging facility - // $this->logging->log($request, $response); - \util\cmd\Console::writeLinef( - " \e[33m[%s %d %.3fkB]\e[0m WS %s %s%s", - date('Y-m-d H:i:s'), - getmypid(), - memory_get_usage() / 1024, - Opcodes::nameOf($type), - $conn->path(), - $hint ? " \e[2m[{$hint}]\e[0m" : '' - ); + $this->logging->log('WS', Opcodes::nameOf($type), $conn->path(), $hints); } } diff --git a/src/test/php/web/unittest/HttpProtocolTest.class.php b/src/test/php/web/unittest/HttpProtocolTest.class.php index a0879f89..93c35419 100755 --- a/src/test/php/web/unittest/HttpProtocolTest.class.php +++ b/src/test/php/web/unittest/HttpProtocolTest.class.php @@ -2,14 +2,15 @@ use io\streams\Streams; use peer\SocketException; -use test\{Assert, AssertionFailed, Test, Values}; +use test\{Assert, Before, AssertionFailed, Test, Values}; use web\{Application, Environment, Logging}; use xp\web\srv\{CannotWrite, HttpProtocol}; class HttpProtocolTest { private $log; - public function __construct() { + #[Before] + public function log() { $this->log= new Logging(null); } @@ -155,7 +156,7 @@ public function catches_write_errors_and_logs_them_as_warning() { throw new CannotWrite('Test error', new SocketException('...')); }); }), - Logging::of(function($req, $res, $hints) use(&$caught) { $caught= $hints['warn']; }) + Logging::of(function($status, $method, $uri, $hints) use(&$caught) { $caught= $hints['warn']; }) ); $this->assertHttp( @@ -177,7 +178,7 @@ public function response_trace_appears_in_log() { $this->application(function($req, $res) { $res->trace('request-time-ms', 1); }), - Logging::of(function($req, $res, $hints) use(&$logged) { $logged= $hints; }) + Logging::of(function($status, $method, $uri, $hints) use(&$logged) { $logged= $hints; }) ); $this->handle($p, ["GET / HTTP/1.1\r\n\r\n"]); diff --git a/src/test/php/web/unittest/LoggingTest.class.php b/src/test/php/web/unittest/LoggingTest.class.php index 02846a2b..99d64bd8 100755 --- a/src/test/php/web/unittest/LoggingTest.class.php +++ b/src/test/php/web/unittest/LoggingTest.class.php @@ -1,16 +1,22 @@ new Error(404, 'Test')]]; + } + + #[Before] + public function noop() { + $this->noop= function($status, $method, $uri, $hints) { }; } #[Test] @@ -20,12 +26,12 @@ public function can_create() { #[Test] public function can_create_with_sink() { - new Logging(new ToFunction(function($req, $res, $error) { })); + new Logging(new ToFunction($this->noop)); } #[Test] public function target() { - $sink= new ToFunction(function($req, $res, $error) { }); + $sink= new ToFunction($this->noop); Assert::equals($sink->target(), (new Logging($sink))->target()); } @@ -40,23 +46,34 @@ public function no_logging_target_of($target) { } #[Test, Values(from: 'arguments')] - public function log($expected, $error) { + public function log($expected, $hints) { + $logged= []; + $log= new Logging(new ToFunction(function($status, $method, $uri, $hints) use(&$logged) { + $logged[]= $method.' '.$uri.($hints ? ' '.$hints['error']->getMessage() : ''); + })); + $log->log(200, 'GET', '/', $hints); + + Assert::equals([$expected], $logged); + } + + #[Test, Values(from: 'arguments')] + public function exchange($expected, $hints) { $req= new Request(new TestInput('GET', '/')); $res= new Response(new TestOutput()); $logged= []; - $log= new Logging(new ToFunction(function($req, $res, $error) use(&$logged) { - $logged[]= $req->method().' '.$req->uri()->path().($error ? ' '.$error->getMessage() : ''); + $log= new Logging(new ToFunction(function($status, $method, $uri, $hints) use(&$logged) { + $logged[]= $method.' '.$uri.($hints ? ' '.$hints['error']->getMessage() : ''); })); - $log->log($req, $res, $error); + $log->exchange($req, $res, $hints); Assert::equals([$expected], $logged); } #[Test] public function pipe() { - $a= new ToFunction(function($req, $res, $error) { /* a */ }); - $b= new ToFunction(function($req, $res, $error) { /* b */ }); + $a= new ToFunction($this->noop); + $b= new ToFunction($this->noop); Assert::equals($b, (new Logging($a))->pipe($b)->sink()); } @@ -67,28 +84,28 @@ public function pipe_with_string_arg() { #[Test] public function tee() { - $a= new ToFunction(function($req, $res, $error) { /* a */ }); - $b= new ToFunction(function($req, $res, $error) { /* b */ }); + $a= new ToFunction($this->noop); + $b= new ToFunction($this->noop); Assert::equals(new ToAllOf($a, $b), (new Logging($a))->tee($b)->sink()); } #[Test] public function tee_multiple() { - $a= new ToFunction(function($req, $res, $error) { /* a */ }); - $b= new ToFunction(function($req, $res, $error) { /* b */ }); - $c= new ToFunction(function($req, $res, $error) { /* c */ }); + $a= new ToFunction($this->noop); + $b= new ToFunction($this->noop); + $c= new ToFunction($this->noop); Assert::equals(new ToAllOf($a, $b, $c), (new Logging($a))->tee($b)->tee($c)->sink()); } #[Test] public function pipe_on_no_logging() { - $sink= new ToFunction(function($req, $res, $error) { }); + $sink= new ToFunction($this->noop); Assert::equals($sink, (new Logging(null))->pipe($sink)->sink()); } #[Test] public function tee_on_no_logging() { - $sink= new ToFunction(function($req, $res, $error) { }); + $sink= new ToFunction($this->noop); Assert::equals($sink, (new Logging(null))->tee($sink)->sink()); } } \ No newline at end of file diff --git a/src/test/php/web/unittest/logging/SinkTest.class.php b/src/test/php/web/unittest/logging/SinkTest.class.php index ba27ff22..6f19e6ad 100755 --- a/src/test/php/web/unittest/logging/SinkTest.class.php +++ b/src/test/php/web/unittest/logging/SinkTest.class.php @@ -19,7 +19,7 @@ public function logging_to_console() { #[Test] public function logging_to_function() { - Assert::instance(ToFunction::class, Sink::of(function($req, $res, $error) { })); + Assert::instance(ToFunction::class, Sink::of(function($status, $method, $uri, $hints) { })); } #[Test] diff --git a/src/test/php/web/unittest/logging/ToAllOfTest.class.php b/src/test/php/web/unittest/logging/ToAllOfTest.class.php index 6f4b597d..355adee5 100755 --- a/src/test/php/web/unittest/logging/ToAllOfTest.class.php +++ b/src/test/php/web/unittest/logging/ToAllOfTest.class.php @@ -1,16 +1,15 @@ ['GET /'], 'b' => ['GET /']], null]; - yield [['a' => ['GET / Test'], 'b' => ['GET / Test']], new Error(404, 'Test')]; + yield [['a' => ['GET /'], 'b' => ['GET /']], []]; + yield [['a' => ['GET / Test'], 'b' => ['GET / Test']], ['error' => new Error(404, 'Test')]]; } #[Test] @@ -20,7 +19,7 @@ public function can_create_without_args() { #[Test] public function can_create_with_sink() { - new ToAllOf(new ToFunction(function($req, $res, $error) { })); + new ToAllOf(new ToFunction(function($status, $method, $uri, $hints) { })); } #[Test] @@ -31,14 +30,14 @@ public function can_create_with_string() { #[Test] public function sinks() { $a= new ToConsole(); - $b= new ToFunction(function($req, $res, $error) { }); + $b= new ToFunction(function($status, $method, $uri, $hints) { }); Assert::equals([$a, $b], (new ToAllOf($a, $b))->sinks()); } #[Test] public function sinks_are_merged_when_passed_ToAllOf_instance() { $a= new ToConsole(); - $b= new ToFunction(function($req, $res, $error) { }); + $b= new ToFunction(function($status, $method, $uri, $hints) { }); Assert::equals([$a, $b], (new ToAllOf(new ToAllOf($a, $b)))->sinks()); } @@ -50,25 +49,22 @@ public function sinks_are_empty_when_created_without_arg() { #[Test] public function targets() { $a= new ToConsole(); - $b= new ToFunction(function($req, $res, $error) { }); + $b= new ToFunction(function($status, $method, $uri, $hints) { }); Assert::equals('(web.logging.ToConsole & web.logging.ToFunction)', (new ToAllOf($a, $b))->target()); } #[Test, Values(from: 'arguments')] - public function logs_to_all($expected, $error) { - $req= new Request(new TestInput('GET', '/')); - $res= new Response(new TestOutput()); - + public function logs_to_all($expected, $hints) { $logged= ['a' => [], 'b' => []]; $sink= new ToAllOf( - new ToFunction(function($req, $res, $error) use(&$logged) { - $logged['a'][]= $req->method().' '.$req->uri()->path().($error ? ' '.$error->getMessage() : ''); + new ToFunction(function($status, $method, $uri, $hints) use(&$logged) { + $logged['a'][]= $method.' '.$uri.($hints ? ' '.$hints['error']->getMessage() : ''); }), - new ToFunction(function($req, $res, $error) use(&$logged) { - $logged['b'][]= $req->method().' '.$req->uri()->path().($error ? ' '.$error->getMessage() : ''); + new ToFunction(function($status, $method, $uri, $hints) use(&$logged) { + $logged['b'][]= $method.' '.$uri.($hints ? ' '.$hints['error']->getMessage() : ''); }) ); - $sink->log($req, $res, $error); + $sink->log(200, 'GET', '/', $hints); Assert::equals($expected, $logged); } diff --git a/src/test/php/web/unittest/logging/ToCategoryTest.class.php b/src/test/php/web/unittest/logging/ToCategoryTest.class.php index 7c28a07e..40bb5e37 100755 --- a/src/test/php/web/unittest/logging/ToCategoryTest.class.php +++ b/src/test/php/web/unittest/logging/ToCategoryTest.class.php @@ -2,9 +2,8 @@ use test\{Assert, Test}; use util\log\{BufferedAppender, LogCategory}; -use web\io\{TestInput, TestOutput}; +use web\Error; use web\logging\ToCategory; -use web\{Error, Request, Response}; class ToCategoryTest { @@ -21,24 +20,19 @@ public function target() { #[Test] public function log() { - $req= new Request(new TestInput('GET', '/')); - $res= new Response(new TestOutput()); - $buffered= new BufferedAppender(); - (new ToCategory((new LogCategory('test'))->withAppender($buffered)))->log($req, $res, []); + (new ToCategory((new LogCategory('test'))->withAppender($buffered)))->log(200, 'GET', '/', []); Assert::notEquals(0, strlen($buffered->getBuffer())); } #[Test] public function log_with_error() { - $req= new Request(new TestInput('GET', '/')); - $res= new Response(new TestOutput()); - $buffered= new BufferedAppender(); (new ToCategory((new LogCategory('test'))->withAppender($buffered)))->log( - $req, - $res, + 404, + 'GET', + '/not-found', ['error' => new Error(404, 'Test')] ); diff --git a/src/test/php/web/unittest/logging/ToConsoleTest.class.php b/src/test/php/web/unittest/logging/ToConsoleTest.class.php index 2009a0e4..5c8b2bae 100755 --- a/src/test/php/web/unittest/logging/ToConsoleTest.class.php +++ b/src/test/php/web/unittest/logging/ToConsoleTest.class.php @@ -3,9 +3,8 @@ use io\streams\MemoryOutputStream; use test\{Assert, Test}; use util\cmd\Console; -use web\io\{TestInput, TestOutput}; +use web\Error; use web\logging\ToConsole; -use web\{Error, Request, Response}; class ToConsoleTest { @@ -16,15 +15,12 @@ class ToConsoleTest { * @return string */ private function log($hints) { - $req= new Request(new TestInput('GET', '/')); - $res= new Response(new TestOutput()); - $memory= new MemoryOutputStream(); $restore= Console::$out->stream(); Console::$out->redirect($memory); try { - (new ToConsole())->log($req, $res, $hints); + (new ToConsole())->log(200, 'GET', '/', $hints); return $memory->bytes(); } finally { Console::$out->redirect($restore); diff --git a/src/test/php/web/unittest/logging/ToFileTest.class.php b/src/test/php/web/unittest/logging/ToFileTest.class.php index b71eefe2..b52b8673 100755 --- a/src/test/php/web/unittest/logging/ToFileTest.class.php +++ b/src/test/php/web/unittest/logging/ToFileTest.class.php @@ -3,9 +3,8 @@ use io\TempFile; use lang\IllegalArgumentException; use test\{After, Before, Assert, Expect, Test}; -use web\io\{TestInput, TestOutput}; +use web\Error; use web\logging\ToFile; -use web\{Error, Request, Response}; class ToFileTest { private $temp; @@ -51,21 +50,13 @@ public function raises_error_if_file_cannot_be_written_to() { #[Test] public function log() { - $req= new Request(new TestInput('GET', '/')); - $res= new Response(new TestOutput()); - - (new ToFile($this->temp))->log($req, $res, []); - + (new ToFile($this->temp))->log(200, 'GET', '/', []); Assert::notEquals(0, $this->temp->size()); } #[Test] public function log_with_error() { - $req= new Request(new TestInput('GET', '/')); - $res= new Response(new TestOutput()); - - (new ToFile($this->temp))->log($req, $res, ['error' => new Error(404, 'Test')]); - + (new ToFile($this->temp))->log(404, 'GET', '/not-found', ['error' => new Error(404, 'Test')]); Assert::notEquals(0, $this->temp->size()); } } \ No newline at end of file From ebfb5dd7cbf2d0f71966e719e7a0e2a31621fe1b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 19:55:38 +0100 Subject: [PATCH 10/79] Prevent exception inside handleDisconnect() and handleError() --- src/main/php/xp/web/srv/Protocol.class.php | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/src/main/php/xp/web/srv/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php index 3f982a6c..536aeb97 100755 --- a/src/main/php/xp/web/srv/Protocol.class.php +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -80,9 +80,10 @@ public function handleData($socket) { */ public function handleDisconnect($socket) { $handle= spl_object_id($socket); - $this->protocols[$handle]->handleDisconnect($socket); - - unset($this->protocols[$handle]); + if (isset($this->protocols[$handle])) { + $this->protocols[$handle]->handleDisconnect($socket); + unset($this->protocols[$handle]); + } $socket->close(); } @@ -94,9 +95,10 @@ public function handleDisconnect($socket) { */ public function handleError($socket, $e) { $handle= spl_object_id($socket); - $this->protocols[$handle]->handleError($socket, $e); - - unset($this->protocols[$handle]); + if (isset($this->protocols[$handle])) { + $this->protocols[$handle]->handleError($socket, $e); + unset($this->protocols[$handle]); + } $socket->close(); } } \ No newline at end of file From d85e29ffc00a99c65daf86fe0cbb77a68acc48e9 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 20:01:19 +0100 Subject: [PATCH 11/79] Remove sequential server, it does not support multiple connections --- src/main/php/xp/web/Servers.class.php | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/src/main/php/xp/web/Servers.class.php b/src/main/php/xp/web/Servers.class.php index b4daf87e..809e6ff1 100755 --- a/src/main/php/xp/web/Servers.class.php +++ b/src/main/php/xp/web/Servers.class.php @@ -1,7 +1,7 @@ Date: Sun, 12 Jan 2025 20:02:21 +0100 Subject: [PATCH 12/79] Remove test for sequential server --- src/test/php/web/unittest/server/ServersTest.class.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/test/php/web/unittest/server/ServersTest.class.php b/src/test/php/web/unittest/server/ServersTest.class.php index 936fab7f..03e78b1d 100755 --- a/src/test/php/web/unittest/server/ServersTest.class.php +++ b/src/test/php/web/unittest/server/ServersTest.class.php @@ -9,7 +9,6 @@ class ServersTest { /** @return iterable */ private function servers() { yield ['async', Servers::$ASYNC]; - yield ['sequential', Servers::$SEQUENTIAL]; yield ['prefork', Servers::$PREFORK]; yield ['develop', Servers::$DEVELOP]; From 70db24d47813fdb795ea0b6f4bdf80c93b00f46b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 23:07:57 +0100 Subject: [PATCH 13/79] Fix logging --- src/main/php/xp/web/WebRunner.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/php/xp/web/WebRunner.class.php b/src/main/php/xp/web/WebRunner.class.php index bac83f86..686335eb 100755 --- a/src/main/php/xp/web/WebRunner.class.php +++ b/src/main/php/xp/web/WebRunner.class.php @@ -47,7 +47,7 @@ private static function error($request, $response, $env, $error) { break; } } - $env->logging()->log($request, $response, $response->trace + ['error' => $error]); + $env->logging()->exchange($request, $response, ['error' => $error]); } /** @@ -83,7 +83,7 @@ public static function main($args) { foreach ($application->service($request, $response) ?? [] as $event => $arg) { if ('delay' === $event) usleep($arg * 1000); } - $env->logging()->log($request, $response, $response->trace); + $env->logging()->exchange($request, $response); } catch (Error $e) { self::error($request, $response, $env, $e); } catch (Throwable $e) { From a0e258ae25652eba51b51f00ae90623ddd302d14 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 12 Jan 2025 23:16:34 +0100 Subject: [PATCH 14/79] Yield all other events after "connection" --- src/main/php/web/Application.class.php | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/main/php/web/Application.class.php b/src/main/php/web/Application.class.php index 20b22c57..80619e56 100755 --- a/src/main/php/web/Application.class.php +++ b/src/main/php/web/Application.class.php @@ -86,6 +86,7 @@ public function service($request, $response) { // Handle dispatching dispatch: $result= $this->routing()->handle($request, $response); + $return= null; if ($result instanceof Traversable) { foreach ($result as $kind => $argument) { if ('dispatch' === $kind) { @@ -98,12 +99,13 @@ public function service($request, $response) { } else if ('connection' === $kind) { $response->header('Connection', 'upgrade'); $response->header('Upgrade', $argument[0]); - return $argument; + $return= $argument; + } else { + yield $kind => $argument; } - - yield $kind => $argument; } } + return $return; } /** @return string */ From abfc4d5ef2535f393842eadfdbce175e0981ddec Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 09:42:46 +0100 Subject: [PATCH 15/79] Run the PHP development webserver as a backend, forwarding requests to it To start, this is a simple reverse proxy setup, which is exactly what happens for HTTP request. Websockets, on the other hand, are kept alive in this server, and its messages are translated to server-sent events, before being passed to the backend, which doesn't support this protocol. This enables the highly effective "save-rerun" developer experience with no intermediary steps like restarting the server (and potentially losing state), or compiling (even with the XP Compiler in place, this will happen "just in time"). We could have chosen any wire format to send the messages back and forth but chose to go with something well-established and standardized, in this case opting for https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events --- src/main/php/web/handler/WebSocket.class.php | 18 ++- src/main/php/web/io/EventSink.class.php | 46 +++++++ src/main/php/web/io/EventSource.class.php | 28 +++++ src/main/php/xp/web/srv/Develop.class.php | 34 +++-- .../php/xp/web/srv/ForwardRequests.class.php | 76 ++++++++++++ src/main/php/xp/web/srv/Input.class.php | 21 +++- .../xp/web/srv/TranslateMessages.class.php | 117 ++++++++++++++++++ .../web/unittest/server/InputTest.class.php | 20 ++- 8 files changed, 341 insertions(+), 19 deletions(-) create mode 100755 src/main/php/web/io/EventSink.class.php create mode 100755 src/main/php/web/io/EventSource.class.php create mode 100755 src/main/php/xp/web/srv/ForwardRequests.class.php create mode 100755 src/main/php/xp/web/srv/TranslateMessages.class.php diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index 7c94ac4c..258626a1 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -1,6 +1,7 @@ header('Sec-WebSocket-Version')) { - case 13: + case 13: // RFC 6455 $key= $request->header('Sec-WebSocket-Key'); $response->answer(101); $response->header('Sec-WebSocket-Accept', base64_encode(sha1($key.self::GUID, true))); @@ -37,6 +38,21 @@ public function handle($request, $response) { } break; + case 9: // Reserved version, use for WS <-> SSE translation + $response->answer(200); + $response->header('Content-Type', 'text/event-stream'); + $response->header('Transfer-Encoding', 'chunked'); + + $events= new EventSink($request, $response); + try { + foreach ($events->receive() as $message) { + $this->listener->message($events, $message); + } + } finally { + $events->flush(); + } + return; + case 0: $response->answer(426); $response->send('This service requires use of the WebSocket protocol', 'text/plain'); diff --git a/src/main/php/web/io/EventSink.class.php b/src/main/php/web/io/EventSink.class.php new file mode 100755 index 00000000..77682462 --- /dev/null +++ b/src/main/php/web/io/EventSink.class.php @@ -0,0 +1,46 @@ +request= $request; + $this->out= $response->stream(); + + $uri= $request->uri()->path(); + if ($query= $request->uri()->query()) { + $uri.= '?'.$query; + } + + parent::__construct(null, null, null, $uri, $request->headers()); + } + + public function receive() { + switch ($mime= $this->request->header('Content-Type')) { + case 'text/plain': yield Opcodes::TEXT => Streams::readAll($this->request->stream()); break; + case 'application/octet-stream': yield Opcodes::BINARY => new Bytes(Streams::readAll($this->request->stream())); break; + default: throw new IllegalStateException('Unexpected content type '.$mime); + } + } + + public function send($message) { + if ($message instanceof Bytes) { + $this->out->write("event: bytes\ndata: ".addcslashes($message, "\r\n")."\n\n"); + } else { + $this->out->write("data: ".addcslashes($message, "\r\n")."\n\n"); + } + } + + public function close($code= 1000, $reason= '') { + $this->out->write("event: close\ndata: ".$code.':'.addcslashes($reason, "\r\n")."\n\n"); + } + + public function flush() { + $this->out->close(); + } +} \ No newline at end of file diff --git a/src/main/php/web/io/EventSource.class.php b/src/main/php/web/io/EventSource.class.php new file mode 100755 index 00000000..c25706f4 --- /dev/null +++ b/src/main/php/web/io/EventSource.class.php @@ -0,0 +1,28 @@ +reader= new StringReader($in); + } + + /** Yields events and associated data */ + public function getIterator(): Traversable { + $event= null; + while ($line= $this->reader->readLine()) { + if (0 === strncmp($line, 'event: ', 7)) { + $event= substr($line, 7); + } else if (0 === strncmp($line, 'data: ', 6)) { + yield $event => substr($line, 6); + $event= null; + } + } + $this->reader->close(); + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 26a1d7d4..828db1aa 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -3,7 +3,8 @@ use io\IOException; use lang\archive\ArchiveClassLoader; use lang\{ClassLoader, CommandLine, FileSystemClassLoader, Runtime, RuntimeOptions}; -use peer\Socket; +use peer\server\AsyncServer; +use peer\{Socket, ServerSocket}; use util\cmd\Console; use web\{Application, Environment, Logging}; use xp\web\Source; @@ -26,6 +27,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $application= (new Source($source, $environment))->application($args); $application->initialize(); + // Start the development webserver in the background // PHP doesn't start with a nonexistant document root if (!$docroot->exists()) { $docroot= getcwd(); @@ -42,7 +44,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo // Start `php -S`, the development webserver $runtime= Runtime::getInstance(); $os= CommandLine::forName(PHP_OS); - $arguments= ['-S', ('localhost' === $this->host ? '127.0.0.1' : $this->host).':'.$this->port, '-t', $docroot]; + $arguments= ['-S', '127.0.0.1:0', '-t', $docroot]; $cmd= $os->compose($runtime->getExecutable()->getFileName(), array_merge( $arguments, $runtime->startupOptions() @@ -67,16 +69,21 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo Console::writeLine("\e[1mServing {$profile}:", $application, $config, "\e[0m > ", $environment->logging()->target()); Console::writeLine("\e[36m", str_repeat('═', 72), "\e[0m"); - if ('WINDOWS' === $os->name()) { - $nul= 'NUL'; - } else { - $nul= '/dev/null'; - $cmd= 'exec '.$cmd; // Replace launching shell with PHP - } - if (!($proc= proc_open($cmd, [STDIN, STDOUT, ['file', $nul, 'w']], $pipes, null, null, ['bypass_shell' => true]))) { + // Replace launching shell with PHP + if ('WINDOWS' !== $os->name()) $cmd= 'exec '.$cmd; + if (!($proc= proc_open($cmd, [STDIN, STDOUT, ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]))) { throw new IOException('Cannot execute `'.$runtime->getExecutable()->getFileName().'`'); } + // Parse `[...] PHP 8.3.15 Development Server (http://127.0.0.1:60922) started` + $line= fgets($pipes[2], 1024); + if (!preg_match('/\([a-z]+:\/\/([0-9.]+):([0-9]+)\)/', $line, $matches)) { + proc_terminate($proc, 2); + proc_close($proc); + throw new IOException('Cannot determine bound port: `'.trim($line).'`'); + } + $backend= new Socket($matches[1], $matches[2]); + Console::writeLinef( "\e[33;1m>\e[0m Server started: \e[35;4mhttp://%s:%d/\e[0m in %.3f seconds\n". " %s - PID %d & %d; press Enter to exit\n", @@ -88,6 +95,15 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo proc_get_status($proc)['pid'] ); + // Start the multiplex protocol in the foreground and forward requests + $impl= new AsyncServer(); + $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() + ->serving('http', new ForwardRequests($backend)) + ->serving('websocket', new TranslateMessages($backend)) + ); + $impl->init(); + $impl->service(); + // Inside `xp -supervise`, connect to signalling socket if ($port= getenv('XP_SIGNAL')) { $s= new Socket('127.0.0.1', $port); diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php new file mode 100755 index 00000000..8a0c2b8d --- /dev/null +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -0,0 +1,76 @@ +backend= $backend; + } + + /** + * Handle client data + * + * @param peer.Socket $socket + * @return void + */ + public function handleData($socket) { + static $exclude= ['Remote-Addr' => true]; + + $request= new Input($socket); + yield from $request->consume(); + + if (Input::REQUEST === $request->kind) { + $this->backend->connect(); + $message= "{$request->method()} {$request->uri()} HTTP/{$request->version()}\r\n"; + $headers= []; + foreach ($request->headers() as $name => $value) { + isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; + $headers[$name]= $value; + } + // \util\cmd\Console::writeLine('>>> ', $message); + $this->backend->write($message."\r\n"); + + if ($stream= $request->incoming()) { + while ($stream->available()) { + $this->backend->write($stream->read()); + } + } + yield 'write' => $this->socket; + + $response= new Input($this->backend); + foreach ($response->consume() as $_) { } + + // Switch protocols + if (101 === $response->status()) { + $result= ['websocket', ['uri' => $request->uri(), 'headers' => $headers]]; + } else { + $result= null; + } + + $message= "HTTP/{$response->version()} {$response->status()} {$response->message()}\r\n"; + foreach ($response->headers() as $name => $value) { + isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; + } + // \util\cmd\Console::writeLine('<<< ', $message); + $socket->write($message."\r\n"); + + if ($stream= $response->incoming()) { + while ($stream->available()) { + $socket->write($stream->read()); + } + } + $this->backend->close(); + + return $result; + } else if (Input::CLOSE === $request->kind) { + $socket->close(); + } else { + // \util\cmd\Console::writeLine('!!! ', $request); + $socket->close(); + } + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Input.class.php b/src/main/php/xp/web/srv/Input.class.php index cf09d1c5..847fe631 100755 --- a/src/main/php/xp/web/srv/Input.class.php +++ b/src/main/php/xp/web/srv/Input.class.php @@ -6,16 +6,18 @@ use web\io\{ReadChunks, ReadLength, Parts, Input as Base}; class Input implements Base { - const REQUEST = 0x01; + const MESSAGE = 0x01; const CLOSE = 0x02; const MALFORMED = 0x04; const EXCESSIVE = 0x08; const TIMEOUT = 0x10; + const REQUEST = 0x21; + const RESPONSE = 0x41; public $kind= null; public $buffer= null; private $socket; - private $method, $uri, $version; + private $method, $uri, $version, $status, $message; private $incoming= null; /** @@ -60,7 +62,10 @@ public function consume($limit= 16384) { } while (true); // Parse status line - if (3 === sscanf($this->buffer, "%s %s HTTP/%[0-9.]\r\n", $this->method, $this->uri, $this->version)) { + if (3 === sscanf($this->buffer, "HTTP/%[0-9.] %d %[^\r]\r\n", $this->version, $this->status, $this->message)) { + $this->kind|= self::RESPONSE; + $this->buffer= substr($this->buffer, strpos($this->buffer, "\r\n") + 2); + } else if (3 === sscanf($this->buffer, "%s %s HTTP/%[0-9.]\r\n", $this->method, $this->uri, $this->version)) { $this->kind|= self::REQUEST; $this->buffer= substr($this->buffer, strpos($this->buffer, "\r\n") + 2); } else { @@ -96,13 +101,19 @@ public function version() { return $this->version; } /** @return string */ public function method() { return $this->method; } - /** @return sring */ + /** @return string */ public function uri() { return $this->uri; } + /** @return int */ + public function status() { return $this->status; } + + /** @return string */ + public function message() { return $this->message; } + /** @return iterable */ public function headers() { yield 'Remote-Addr' => $this->socket->remoteEndpoint()->getHost(); - if (self::REQUEST !== $this->kind) return; + if (0 === ($this->kind & self::MESSAGE)) return; while ($line= $this->readLine()) { sscanf($line, "%[^:]: %[^\r]", $name, $value); diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php new file mode 100755 index 00000000..f57f7e9b --- /dev/null +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -0,0 +1,117 @@ +backend= $backend; + } + + /** + * Handle client switch + * + * @param peer.Socket $socket + * @param var $context + */ + public function handleSwitch($socket, $context) { + $socket->setTimeout(600); + $socket->useNoDelay(); + + $id= spl_object_id($socket); + $this->connections[$id]= new Connection( + $socket, + $id, + null, + $context['uri'], + $context['headers'] + ); + $this->connections[$id]->open(); + } + + private function decode($data) { + return strtr($data, ['\r' => "\r", '\n' => "\n"]); + } + + /** + * Handle client data + * + * @param peer.Socket $socket + * @return void + */ + public function handleData($socket) { + static $mime= [Opcodes::TEXT => 'text/plain', Opcodes::BINARY => 'application/octet-stream']; + + $conn= $this->connections[spl_object_id($socket)]; + foreach ($conn->receive() as $type => $message) { + if (Opcodes::CLOSE === $type) { + $conn->close(); + } else { + $request= "POST {$conn->path()} HTTP/1.1\r\n"; + $headers= ['Sec-WebSocket-Version' => 9, 'Content-Type' => $mime[$type], 'Content-Length' => strlen($message)]; + foreach ($headers + $conn->headers() as $name => $value) { + $request.= "{$name}: {$value}\r\n"; + } + + try { + $this->backend->connect(); + $this->backend->write($request."\r\n".$message); + + $response= new Input($this->backend); + foreach ($response->consume() as $_) { } + if (200 !== $response->status()) { + throw new IllegalStateException('Unexpected status code from backend://'.$conn->path().': '.$response->status()); + } + + // Process SSE stream + foreach ($response->headers() as $_) { } + foreach (new EventSource($response->incoming()) as $event => $data) { + $value= strtr($data, ['\r' => "\r", '\n' => "\n"]); + switch ($event) { + case null: case 'text': $conn->send($value); break; + case 'bytes': $conn->send(new Bytes($value)); break; + case 'close': $conn->close(...explode(':', $value)); break; + default: throw new IllegalStateException('Unexpected event '.$event); + } + } + } catch (Any $e) { + $conn->close(1011, $e->getMessage()); + } finally { + $this->backend->close(); + } + } + } + } + + /** + * Handle client disconnect + * + * @param peer.Socket $socket + */ + public function handleDisconnect($socket) { + unset($this->connections[spl_object_id($socket)]); + } + + /** + * Handle I/O error + * + * @param peer.Socket $socket + * @param lang.XPException $e + */ + public function handleError($socket, $e) { + unset($this->connections[spl_object_id($socket)]); + } +} \ No newline at end of file diff --git a/src/test/php/web/unittest/server/InputTest.class.php b/src/test/php/web/unittest/server/InputTest.class.php index 992ff588..39cf9de3 100755 --- a/src/test/php/web/unittest/server/InputTest.class.php +++ b/src/test/php/web/unittest/server/InputTest.class.php @@ -77,10 +77,22 @@ public function close_kind() { #[Test] public function request_kind() { - Assert::equals( - Input::REQUEST, - $this->consume($this->socket("GET / HTTP/1.1\r\n\r\n"))->kind - ); + $input= $this->consume($this->socket("GET / HTTP/1.1\r\n\r\n")); + + Assert::equals(Input::REQUEST, $input->kind); + Assert::equals('GET', $input->method()); + Assert::equals('/', $input->uri()); + Assert::equals('1.1', $input->version()); + } + + #[Test] + public function response_kind() { + $input= $this->consume($this->socket("HTTP/1.1 101 Switching Protocols\r\n\r\n")); + + Assert::equals(Input::RESPONSE, $input->kind); + Assert::equals('1.1', $input->version()); + Assert::equals(101, $input->status()); + Assert::equals('Switching Protocols', $input->message()); } #[Test] From a314a060f8d5ce4491afbfd74bd75df110078c30 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 10:30:14 +0100 Subject: [PATCH 16/79] Test binary messages --- .../web/unittest/IntegrationTest.class.php | 25 +++++--- .../web/unittest/TestingApplication.class.php | 7 ++- src/main/php/xp/web/srv/WsProtocol.class.php | 58 ++++++++++++++++--- 3 files changed, 71 insertions(+), 19 deletions(-) diff --git a/src/it/php/web/unittest/IntegrationTest.class.php b/src/it/php/web/unittest/IntegrationTest.class.php index 678dca08..fe61f5a2 100755 --- a/src/it/php/web/unittest/IntegrationTest.class.php +++ b/src/it/php/web/unittest/IntegrationTest.class.php @@ -1,11 +1,12 @@ server= $server; } - #[After] - public function shutdown() { - $this->server->shutdown(); + /** @return iterable */ + private function messages() { + yield ['Test', 'Echo: Test']; + yield [new Bytes([8, 15]), new Bytes([47, 11, 8, 15])]; } /** @@ -163,16 +165,21 @@ public function with_large_cookie($length) { Assert::equals((string)strlen($header), $r['body']); } - #[Test] - public function websocket_message() { + #[Test, Values(from: 'messages')] + public function websocket_message($input, $output) { try { $ws= new WebSocket($this->server->connection, '/ws'); $ws->connect(); - $ws->send('Test'); - $echo= $ws->receive(); + $ws->send($input); + $result= $ws->receive(); } finally { $ws->close(); } - Assert::equals('Echo: Test', $echo); + Assert::equals($output, $result); + } + + #[After] + public function shutdown() { + $this->server->shutdown(); } } \ No newline at end of file diff --git a/src/it/php/web/unittest/TestingApplication.class.php b/src/it/php/web/unittest/TestingApplication.class.php index 4c9d7676..6fd6b351 100755 --- a/src/it/php/web/unittest/TestingApplication.class.php +++ b/src/it/php/web/unittest/TestingApplication.class.php @@ -2,6 +2,7 @@ use lang\XPClass; use test\Assert; +use util\Bytes; use web\handler\WebSocket; use web\{Application, Error}; @@ -11,7 +12,11 @@ class TestingApplication extends Application { public function routes() { return [ '/ws' => new WebSocket(function($conn, $payload) { - $conn->send('Echo: '.$payload); + if ($payload instanceof Bytes) { + $conn->send(new Bytes("\057\013{$payload}")); + } else { + $conn->send('Echo: '.$payload); + } }), '/status/420' => function($req, $res) { $res->answer(420, $req->param('message') ?? 'Enhance your calm'); diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WsProtocol.class.php index 5b649da0..5b48a150 100755 --- a/src/main/php/xp/web/srv/WsProtocol.class.php +++ b/src/main/php/xp/web/srv/WsProtocol.class.php @@ -1,7 +1,8 @@ connections[spl_object_id($socket)]; - foreach ($conn->receive() as $type => $message) { + foreach ($conn->receive() as $opcode => $payload) { try { - if (Opcodes::CLOSE === $type) { - $conn->close(); - $hints= unpack('nstatus/a*reason', $message); - } else { - yield from $conn->on($message) ?? []; - $hints= []; + switch ($opcode) { + case Opcodes::TEXT: + if (!preg_match('//u', $payload)) { + $conn->answer(Opcodes::CLOSE, pack('n', 1007)); + $hints= ['error' => new FormatException('Malformed payload')]; + $socket->close(); + break; + } + + yield from $conn->on($payload) ?? []; + $hints= []; + break; + + case Opcodes::BINARY: + yield from $conn->on(new Bytes($payload)) ?? []; + $hints= []; + break; + + case Opcodes::PING: // Answer a PING frame with a PONG + $conn->answer(Opcodes::PONG, $payload); + $hints= []; + break; + + case Opcodes::PONG: // Do not answer PONGs + $hints= []; + break; + + case Opcodes::CLOSE: // Close connection + if ('' === $payload) { + $close= ['code' => 1000, 'reason' => '']; + } else { + $close= unpack('ncode/a*reason', $payload); + if (!preg_match('//u', $close['reason'])) { + $close= ['code' => 1007, 'reason' => '']; + } else if ($close['code'] > 2999 || in_array($close['code'], [1000, 1001, 1002, 1003, 1007, 1008, 1009, 1010, 1011])) { + // Answer with client code and reason + } else { + $close= ['code' => 1002, 'reason' => '']; + } + } + + $conn->answer(Opcodes::CLOSE, pack('na*', $close['code'], $close['reason'])); + $conn->close(); + $hints= $close; + break; } } catch (Any $e) { - $hint= ['error' => Throwable::wrap($e)]; + $hints= ['error' => Throwable::wrap($e)]; } $this->logging->log('WS', Opcodes::nameOf($type), $conn->path(), $hints); From d013905f395748fbcb669adc144802e4c233bef8 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 10:31:46 +0100 Subject: [PATCH 17/79] QA: Imports --- src/it/php/web/unittest/TestingApplication.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/it/php/web/unittest/TestingApplication.class.php b/src/it/php/web/unittest/TestingApplication.class.php index 6fd6b351..78d71714 100755 --- a/src/it/php/web/unittest/TestingApplication.class.php +++ b/src/it/php/web/unittest/TestingApplication.class.php @@ -1,7 +1,7 @@ function($req, $res) { $class= XPClass::forName(basename($req->uri()->path())); - if ($class->isSubclassOf(\Throwable::class)) throw $class->newInstance('Raised'); + if ($class->isSubclassOf(Throwable::class)) throw $class->newInstance('Raised'); // A non-exception class was passed! $res->answer(200, 'No error'); From db560b26d240a8199dca2c7e06c273a548b9aa3b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 10:47:05 +0100 Subject: [PATCH 18/79] Add test for EventSource implementation --- src/main/php/web/io/EventSource.class.php | 10 ++++- .../web/unittest/io/EventSourceTest.class.php | 39 +++++++++++++++++++ 2 files changed, 47 insertions(+), 2 deletions(-) create mode 100755 src/test/php/web/unittest/io/EventSourceTest.class.php diff --git a/src/main/php/web/io/EventSource.class.php b/src/main/php/web/io/EventSource.class.php index c25706f4..519124fb 100755 --- a/src/main/php/web/io/EventSource.class.php +++ b/src/main/php/web/io/EventSource.class.php @@ -3,7 +3,13 @@ use IteratorAggregate, Traversable; use io\streams\{InputStream, StringReader}; -/** @see https://developer.mozilla.org/en-US/docs/Web/API/EventSource */ +/** + * Event source is the receiving end for server-sent events, handling the + * `text/event-stream` wire format. + * + * @test web.unittest.io.EventSourceTest + * @see https://developer.mozilla.org/en-US/docs/Web/API/EventSource + */ class EventSource implements IteratorAggregate { private $reader; @@ -15,7 +21,7 @@ public function __construct(InputStream $in) { /** Yields events and associated data */ public function getIterator(): Traversable { $event= null; - while ($line= $this->reader->readLine()) { + while (null !== ($line= $this->reader->readLine())) { if (0 === strncmp($line, 'event: ', 7)) { $event= substr($line, 7); } else if (0 === strncmp($line, 'data: ', 6)) { diff --git a/src/test/php/web/unittest/io/EventSourceTest.class.php b/src/test/php/web/unittest/io/EventSourceTest.class.php new file mode 100755 index 00000000..ceb8a50c --- /dev/null +++ b/src/test/php/web/unittest/io/EventSourceTest.class.php @@ -0,0 +1,39 @@ + 'One']]]; + yield [['', 'data: One'], [[null => 'One']]]; + yield [['data: One', '', 'data: Two'], [[null => 'One'], [null => 'Two']]]; + yield [['event: test', 'data: One'], [['test' => 'One']]]; + yield [['event: test', 'data: One', '', 'data: Two'], [['test' => 'One'], [null => 'Two']]]; + } + + #[Test] + public function can_create() { + new EventSource($this->stream([])); + } + + #[Test, Values(from: 'inputs')] + public function events($lines, $expected) { + $events= new EventSource($this->stream($lines)); + $actual= []; + foreach ($events as $type => $event) { + $actual[]= [$type => $event]; + } + Assert::equals($expected, $actual); + } +} \ No newline at end of file From 0ea9a1097383eaa11c9137d373951c140df71a23 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 10:49:01 +0100 Subject: [PATCH 19/79] Simplify test, add new variation --- .../php/web/unittest/io/EventSourceTest.class.php | 13 +++++-------- 1 file changed, 5 insertions(+), 8 deletions(-) diff --git a/src/test/php/web/unittest/io/EventSourceTest.class.php b/src/test/php/web/unittest/io/EventSourceTest.class.php index ceb8a50c..8d45ea25 100755 --- a/src/test/php/web/unittest/io/EventSourceTest.class.php +++ b/src/test/php/web/unittest/io/EventSourceTest.class.php @@ -1,35 +1,32 @@ 'One']]]; yield [['', 'data: One'], [[null => 'One']]]; + yield [['data: One', ''], [[null => 'One']]]; yield [['data: One', '', 'data: Two'], [[null => 'One'], [null => 'Two']]]; yield [['event: test', 'data: One'], [['test' => 'One']]]; yield [['event: test', 'data: One', '', 'data: Two'], [['test' => 'One'], [null => 'Two']]]; + yield [['event: one', 'data: 1', '', 'event: two', 'data: 2'], [['one' => '1'], ['two' => '2']]]; } #[Test] public function can_create() { - new EventSource($this->stream([])); + new EventSource(new MemoryInputStream('')); } #[Test, Values(from: 'inputs')] public function events($lines, $expected) { - $events= new EventSource($this->stream($lines)); + $events= new EventSource(new MemoryInputStream(implode("\n", $lines))); $actual= []; foreach ($events as $type => $event) { $actual[]= [$type => $event]; From 933349e89767020eff6dd5b2a00d6efe91ee1189 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 11:26:46 +0100 Subject: [PATCH 20/79] Fix logging --- src/main/php/xp/web/srv/WsProtocol.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WsProtocol.class.php index 5b48a150..5b345620 100755 --- a/src/main/php/xp/web/srv/WsProtocol.class.php +++ b/src/main/php/xp/web/srv/WsProtocol.class.php @@ -99,7 +99,7 @@ public function handleData($socket) { $hints= ['error' => Throwable::wrap($e)]; } - $this->logging->log('WS', Opcodes::nameOf($type), $conn->path(), $hints); + $this->logging->log('WS', Opcodes::nameOf($opcode), $conn->path(), $hints); } } From b8a57dfeea3231f22721dece30ff5d5b5d219424 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 12:10:54 +0100 Subject: [PATCH 21/79] Fix reference to undefined property --- src/main/php/xp/web/srv/ForwardRequests.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 8a0c2b8d..005f5c67 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -39,7 +39,7 @@ public function handleData($socket) { $this->backend->write($stream->read()); } } - yield 'write' => $this->socket; + yield 'write' => $socket; $response= new Input($this->backend); foreach ($response->consume() as $_) { } From 001e05693e6568c173209b46a43152477d3a336c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 12:16:18 +0100 Subject: [PATCH 22/79] Return an empty string when reading after end of chunked data --- src/main/php/web/io/ReadChunks.class.php | 3 ++- .../php/web/unittest/io/ReadChunksTest.class.php | 12 +++--------- 2 files changed, 5 insertions(+), 10 deletions(-) diff --git a/src/main/php/web/io/ReadChunks.class.php b/src/main/php/web/io/ReadChunks.class.php index bf7621d1..c11518bc 100755 --- a/src/main/php/web/io/ReadChunks.class.php +++ b/src/main/php/web/io/ReadChunks.class.php @@ -76,11 +76,12 @@ public function line() { */ public function read($limit= 8192) { $remaining= $this->remaining ?? $this->scan(); + if (0 === $remaining) return ''; // Expected EOF $chunk= substr($this->buffer, 0, min($limit, $remaining)); if ('' === $chunk) { $this->remaining= 0; - throw new IOException('EOF'); + throw new IOException('Unexpected EOF'); } $length= strlen($chunk); diff --git a/src/test/php/web/unittest/io/ReadChunksTest.class.php b/src/test/php/web/unittest/io/ReadChunksTest.class.php index 164b5324..0e86a631 100755 --- a/src/test/php/web/unittest/io/ReadChunksTest.class.php +++ b/src/test/php/web/unittest/io/ReadChunksTest.class.php @@ -130,21 +130,15 @@ public function raises_exception_on_eof_in_the_middle_of_data() { $fixture= new ReadChunks($this->input("ff\r\n...💣")); $fixture->read(); - try { - $fixture->read(1); - $this->fail('No exception raised', null, IOException::class); - } catch (IOException $expected) { } + Assert::throws(IOException::class, fn() => $fixture->read(1)); } #[Test, Values([4, 8192])] - public function reading_after_eof_raises_exception($length) { + public function reading_after_eof_returns_empty_string($length) { $fixture= new ReadChunks($this->input("4\r\nTest\r\n0\r\n\r\n")); $fixture->read($length); - try { - $fixture->read(1); - $this->fail('No exception raised', null, IOException::class); - } catch (IOException $expected) { } + Assert::equals('', $fixture->read(1)); } #[Test] From b03894ebcde92049cfddf8ee5a5f7de2a1ae1cd7 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 12:16:53 +0100 Subject: [PATCH 23/79] Correctly end chunked output stream --- src/main/php/web/io/EventSink.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/web/io/EventSink.class.php b/src/main/php/web/io/EventSink.class.php index 77682462..80a5d38a 100755 --- a/src/main/php/web/io/EventSink.class.php +++ b/src/main/php/web/io/EventSink.class.php @@ -41,6 +41,6 @@ public function close($code= 1000, $reason= '') { } public function flush() { - $this->out->close(); + $this->out->finish(); } } \ No newline at end of file From 57bf8cf9dae13b1de008f890f973988e922ef82f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 12:18:59 +0100 Subject: [PATCH 24/79] QA: Remove unused helper --- src/main/php/xp/web/srv/TranslateMessages.class.php | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index f57f7e9b..3e0f33e0 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -42,10 +42,6 @@ public function handleSwitch($socket, $context) { $this->connections[$id]->open(); } - private function decode($data) { - return strtr($data, ['\r' => "\r", '\n' => "\n"]); - } - /** * Handle client data * From f7ab152c5b16b6e3309e2deadfaa6de6e6dd987e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 14:15:38 +0100 Subject: [PATCH 25/79] Extract common parts from the implementation of WS<->SSE translation --- src/main/php/web/handler/WebSocket.class.php | 10 +- src/main/php/web/io/EventSink.class.php | 6 +- src/main/php/xp/web/srv/Develop.class.php | 2 +- .../php/xp/web/srv/ForwardRequests.class.php | 2 +- .../xp/web/srv/TranslateMessages.class.php | 116 +++++------------- src/main/php/xp/web/srv/WsProtocol.class.php | 12 +- 6 files changed, 55 insertions(+), 93 deletions(-) diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index 258626a1..4d03e5cf 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -64,6 +64,14 @@ public function handle($request, $response) { return; } - yield 'connection' => ['websocket', ['request' => $request, 'listener' => $this->listener]]; + $path= $request->uri()->path(); + if ($query= $request->uri()->query()) { + $path.= '?'.$query; + } + yield 'connection' => ['websocket', [ + 'path' => $path, + 'headers' => $request->headers(), + 'listener' => $this->listener, + ]]; } } \ No newline at end of file diff --git a/src/main/php/web/io/EventSink.class.php b/src/main/php/web/io/EventSink.class.php index 80a5d38a..dc1acda8 100755 --- a/src/main/php/web/io/EventSink.class.php +++ b/src/main/php/web/io/EventSink.class.php @@ -12,12 +12,12 @@ public function __construct($request, $response) { $this->request= $request; $this->out= $response->stream(); - $uri= $request->uri()->path(); + $path= $request->uri()->path(); if ($query= $request->uri()->query()) { - $uri.= '?'.$query; + $path.= '?'.$query; } - parent::__construct(null, null, null, $uri, $request->headers()); + parent::__construct(null, null, null, $path, $request->headers()); } public function receive() { diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 828db1aa..bd8529c2 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -99,7 +99,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() ->serving('http', new ForwardRequests($backend)) - ->serving('websocket', new TranslateMessages($backend)) + ->serving('websocket', new WsProtocol(Logging::of(null), new TranslateMessages($backend))) ); $impl->init(); $impl->service(); diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 005f5c67..6767d16c 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -46,7 +46,7 @@ public function handleData($socket) { // Switch protocols if (101 === $response->status()) { - $result= ['websocket', ['uri' => $request->uri(), 'headers' => $headers]]; + $result= ['websocket', ['path' => $request->uri(), 'headers' => $headers]]; } else { $result= null; } diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index 3e0f33e0..70d9bc12 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -5,15 +5,14 @@ use peer\Socket; use util\Bytes; use web\io\EventSource; -use websocket\protocol\{Opcodes, Connection}; +use websocket\Listener; /** * Translates websocket messages into HTTP requests to an SSE endpoint * * @see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events */ -class TranslateMessages extends Switchable { - private $connections= []; +class TranslateMessages extends Listener { private $backend; /** Creates a new instance */ @@ -22,92 +21,45 @@ public function __construct(Socket $backend) { } /** - * Handle client switch + * Handles incoming message * - * @param peer.Socket $socket - * @param var $context + * @param websocket.protocol.Connection $conn + * @param string|util.Bytes $message + * @return var */ - public function handleSwitch($socket, $context) { - $socket->setTimeout(600); - $socket->useNoDelay(); - - $id= spl_object_id($socket); - $this->connections[$id]= new Connection( - $socket, - $id, - null, - $context['uri'], - $context['headers'] - ); - $this->connections[$id]->open(); - } - - /** - * Handle client data - * - * @param peer.Socket $socket - * @return void - */ - public function handleData($socket) { - static $mime= [Opcodes::TEXT => 'text/plain', Opcodes::BINARY => 'application/octet-stream']; - - $conn= $this->connections[spl_object_id($socket)]; - foreach ($conn->receive() as $type => $message) { - if (Opcodes::CLOSE === $type) { - $conn->close(); - } else { - $request= "POST {$conn->path()} HTTP/1.1\r\n"; - $headers= ['Sec-WebSocket-Version' => 9, 'Content-Type' => $mime[$type], 'Content-Length' => strlen($message)]; - foreach ($headers + $conn->headers() as $name => $value) { - $request.= "{$name}: {$value}\r\n"; - } + public function message($conn, $message) { + $type= $message instanceof Bytes ? 'application/octet-stream' : 'text/plain'; + $request= "POST {$conn->path()} HTTP/1.1\r\n"; + $headers= ['Sec-WebSocket-Version' => 9, 'Content-Type' => $type, 'Content-Length' => strlen($message)]; + foreach ($headers + $conn->headers() as $name => $value) { + $request.= "{$name}: {$value}\r\n"; + } - try { - $this->backend->connect(); - $this->backend->write($request."\r\n".$message); + try { + $this->backend->connect(); + $this->backend->write($request."\r\n".$message); - $response= new Input($this->backend); - foreach ($response->consume() as $_) { } - if (200 !== $response->status()) { - throw new IllegalStateException('Unexpected status code from backend://'.$conn->path().': '.$response->status()); - } + $response= new Input($this->backend); + foreach ($response->consume() as $_) { } + if (200 !== $response->status()) { + throw new IllegalStateException('Unexpected status code from backend://'.$conn->path().': '.$response->status()); + } - // Process SSE stream - foreach ($response->headers() as $_) { } - foreach (new EventSource($response->incoming()) as $event => $data) { - $value= strtr($data, ['\r' => "\r", '\n' => "\n"]); - switch ($event) { - case null: case 'text': $conn->send($value); break; - case 'bytes': $conn->send(new Bytes($value)); break; - case 'close': $conn->close(...explode(':', $value)); break; - default: throw new IllegalStateException('Unexpected event '.$event); - } - } - } catch (Any $e) { - $conn->close(1011, $e->getMessage()); - } finally { - $this->backend->close(); + // Process SSE stream + foreach ($response->headers() as $_) { } + foreach (new EventSource($response->incoming()) as $event => $data) { + $value= strtr($data, ['\r' => "\r", '\n' => "\n"]); + switch ($event) { + case null: case 'text': $conn->send($value); break; + case 'bytes': $conn->send(new Bytes($value)); break; + case 'close': $conn->close(...explode(':', $value)); break; + default: throw new IllegalStateException('Unexpected event '.$event); } } + } catch (Any $e) { + $conn->close(1011, $e->getMessage()); + } finally { + $this->backend->close(); } } - - /** - * Handle client disconnect - * - * @param peer.Socket $socket - */ - public function handleDisconnect($socket) { - unset($this->connections[spl_object_id($socket)]); - } - - /** - * Handle I/O error - * - * @param peer.Socket $socket - * @param lang.XPException $e - */ - public function handleError($socket, $e) { - unset($this->connections[spl_object_id($socket)]); - } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WsProtocol.class.php index 5b345620..9b62d506 100755 --- a/src/main/php/xp/web/srv/WsProtocol.class.php +++ b/src/main/php/xp/web/srv/WsProtocol.class.php @@ -6,16 +6,18 @@ use websocket\protocol\{Opcodes, Connection}; class WsProtocol extends Switchable { - private $logging; + private $logging, $listener; private $connections= []; /** * Creates a new protocol instance * * @param web.Logging $logging + * @param ?websocket.Listener $listener */ - public function __construct($logging) { + public function __construct($logging, $listener= null) { $this->logging= $logging; + $this->listener= $listener; } /** @@ -32,9 +34,9 @@ public function handleSwitch($socket, $context) { $this->connections[$id]= new Connection( $socket, $id, - $context['listener'], - $context['request']->uri()->path(), - $context['request']->headers() + $context['listener'] ?? $this->listener, + $context['path'], + $context['headers'] ); $this->connections[$id]->open(); } From 04ad79bf944131c193347bab7c8428f736ea0699 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 14:52:22 +0100 Subject: [PATCH 26/79] Add tests for WS<->SSE translation --- .../xp/web/srv/TranslateMessages.class.php | 3 +- src/test/php/web/unittest/Channel.class.php | 6 +- .../server/TranslateMessagesTest.class.php | 74 +++++++++++++++++++ 3 files changed, 80 insertions(+), 3 deletions(-) create mode 100755 src/test/php/web/unittest/server/TranslateMessagesTest.class.php diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index 70d9bc12..880d050b 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -10,7 +10,8 @@ /** * Translates websocket messages into HTTP requests to an SSE endpoint * - * @see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events + * @test web.unittest.server.TranslateMessagesTest + * @see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events */ class TranslateMessages extends Listener { private $backend; diff --git a/src/test/php/web/unittest/Channel.class.php b/src/test/php/web/unittest/Channel.class.php index 13ef0867..6f3bf2c5 100755 --- a/src/test/php/web/unittest/Channel.class.php +++ b/src/test/php/web/unittest/Channel.class.php @@ -1,13 +1,15 @@ in= $chunks; } + public function connect($timeout= 2.0) { $this->closed= false; } + public function remoteEndpoint() { return new SocketEndpoint('127.0.0.1', 6666); } public function canRead($timeout= 0.0) { return !empty($this->in); } diff --git a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php new file mode 100755 index 00000000..34424145 --- /dev/null +++ b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php @@ -0,0 +1,74 @@ +message( + 'POST /ws HTTP/1.1', + 'Sec-WebSocket-Version: 9', + 'Content-Type: text/plain', + 'Content-Length: 4', + '', + 'Test', + ); + $response= $this->message( + 'HTTP/1.1 200 OK', + 'Content-Type: text/event-stream', + 'Transfer-Encoding: chunked', + '', + "d\r\ndata: Tested\n\r\n0\r\n\r\n" + ); + + $backend= new Channel([$response]); + $ws= new Channel([]); + $fixture= new TranslateMessages($backend); + $fixture->message(new Connection($ws, 1, null, '/ws', []), 'Test'); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\201\006Tested", implode('', $ws->out)); + } + + #[Test] + public function binary() { + $request= $this->message( + 'POST /ws HTTP/1.1', + 'Sec-WebSocket-Version: 9', + 'Content-Type: application/octet-stream', + 'Content-Length: 2', + '', + "\010\017", + ); + $response= $this->message( + 'HTTP/1.1 200 OK', + 'Content-Type: text/event-stream', + 'Transfer-Encoding: chunked', + '', + "15\r\nevent: bytes\ndata: \047\011\n\r\n0\r\n\r\n" + ); + + $backend= new Channel([$response]); + $ws= new Channel([]); + $fixture= new TranslateMessages($backend); + $fixture->message(new Connection($ws, 1, null, '/ws', []), new Bytes([8, 15])); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\202\002\047\011", implode('', $ws->out)); + } +} \ No newline at end of file From af6f9088b15ae1c666dd38ee46aaf9bdc905e15e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 15:09:09 +0100 Subject: [PATCH 27/79] Test handling of backend errors when translating SSE to WebSockets --- .../xp/web/srv/TranslateMessages.class.php | 2 ++ src/test/php/web/unittest/Channel.class.php | 2 ++ .../server/TranslateMessagesTest.class.php | 30 +++++++++++++++++++ 3 files changed, 34 insertions(+) diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index 880d050b..69cb6aa0 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -6,6 +6,7 @@ use util\Bytes; use web\io\EventSource; use websocket\Listener; +use websocket\protocol\Opcodes; /** * Translates websocket messages into HTTP requests to an SSE endpoint @@ -58,6 +59,7 @@ public function message($conn, $message) { } } } catch (Any $e) { + $conn->answer(Opcodes::CLOSE, pack('na*', 1011, $e->getMessage())); $conn->close(1011, $e->getMessage()); } finally { $this->backend->close(); diff --git a/src/test/php/web/unittest/Channel.class.php b/src/test/php/web/unittest/Channel.class.php index 6f3bf2c5..3c95c0c4 100755 --- a/src/test/php/web/unittest/Channel.class.php +++ b/src/test/php/web/unittest/Channel.class.php @@ -10,6 +10,8 @@ public function __construct($chunks) { $this->in= $chunks; } public function connect($timeout= 2.0) { $this->closed= false; } + public function isConnected() { return !$this->closed; } + public function remoteEndpoint() { return new SocketEndpoint('127.0.0.1', 6666); } public function canRead($timeout= 0.0) { return !empty($this->in); } diff --git a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php index 34424145..2fc3c796 100755 --- a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php +++ b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php @@ -43,6 +43,7 @@ public function text() { Assert::equals($request, implode('', $backend->out)); Assert::equals("\201\006Tested", implode('', $ws->out)); + Assert::true($ws->isConnected()); } #[Test] @@ -70,5 +71,34 @@ public function binary() { Assert::equals($request, implode('', $backend->out)); Assert::equals("\202\002\047\011", implode('', $ws->out)); + Assert::true($ws->isConnected()); + } + + #[Test] + public function backend_error() { + $request= $this->message( + 'POST /ws HTTP/1.1', + 'Sec-WebSocket-Version: 9', + 'Content-Type: text/plain', + 'Content-Length: 4', + '', + 'Test', + ); + $response= $this->message( + 'HTTP/1.1 500 Internal Server Errror', + 'Content-Type: text/plain', + 'Content-Length: 7', + '', + 'Testing' + ); + + $backend= new Channel([$response]); + $ws= new Channel([]); + $fixture= new TranslateMessages($backend); + $fixture->message(new Connection($ws, 1, null, '/ws', []), 'Test'); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\210\060\003\363Unexpected status code from backend:///ws: 500", implode('', $ws->out)); + Assert::false($ws->isConnected()); } } \ No newline at end of file From 0b88cf0a503bcb417f09bca3cba4503d9c689d8b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 15:23:21 +0100 Subject: [PATCH 28/79] Test "close" message when translating SSE to WebSockets --- .../xp/web/srv/TranslateMessages.class.php | 9 ++++-- .../server/TranslateMessagesTest.class.php | 28 +++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index 69cb6aa0..fdd3716a 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -54,13 +54,18 @@ public function message($conn, $message) { switch ($event) { case null: case 'text': $conn->send($value); break; case 'bytes': $conn->send(new Bytes($value)); break; - case 'close': $conn->close(...explode(':', $value)); break; + case 'close': { + sscanf($value, "%d:%[^\r]", $code, $reason); + $conn->answer(Opcodes::CLOSE, pack('na*', $code, $reason)); + $conn->close(); + break; + } default: throw new IllegalStateException('Unexpected event '.$event); } } } catch (Any $e) { $conn->answer(Opcodes::CLOSE, pack('na*', 1011, $e->getMessage())); - $conn->close(1011, $e->getMessage()); + $conn->close(); } finally { $this->backend->close(); } diff --git a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php index 2fc3c796..4fee5b16 100755 --- a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php +++ b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php @@ -74,6 +74,34 @@ public function binary() { Assert::true($ws->isConnected()); } + #[Test] + public function close() { + $request= $this->message( + 'POST /ws HTTP/1.1', + 'Sec-WebSocket-Version: 9', + 'Content-Type: application/octet-stream', + 'Content-Length: 2', + '', + "\010\017", + ); + $response= $this->message( + 'HTTP/1.1 200 OK', + 'Content-Type: text/event-stream', + 'Transfer-Encoding: chunked', + '', + "1d\r\nevent: close\ndata: 1011:Error\r\n0\r\n\r\n" + ); + + $backend= new Channel([$response]); + $ws= new Channel([]); + $fixture= new TranslateMessages($backend); + $fixture->message(new Connection($ws, 1, null, '/ws', []), new Bytes([8, 15])); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\210\007\003\363Error", implode('', $ws->out)); + Assert::false($ws->isConnected()); + } + #[Test] public function backend_error() { $request= $this->message( From 5d88f89ecf1d27130ca439aebbfcf7bfe2c45ed2 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 15:27:40 +0100 Subject: [PATCH 29/79] Test unexpected events when translating SSE to WebSockets --- .../xp/web/srv/TranslateMessages.class.php | 2 +- .../server/TranslateMessagesTest.class.php | 28 +++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index fdd3716a..5a22fb53 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -60,7 +60,7 @@ public function message($conn, $message) { $conn->close(); break; } - default: throw new IllegalStateException('Unexpected event '.$event); + default: throw new IllegalStateException('Unexpected event from backend://'.$conn->path().': '.$event); } } } catch (Any $e) { diff --git a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php index 4fee5b16..e81414e4 100755 --- a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php +++ b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php @@ -102,6 +102,34 @@ public function close() { Assert::false($ws->isConnected()); } + #[Test] + public function unexpected_type() { + $request= $this->message( + 'POST /ws HTTP/1.1', + 'Sec-WebSocket-Version: 9', + 'Content-Type: text/plain', + 'Content-Length: 4', + '', + 'Test', + ); + $response= $this->message( + 'HTTP/1.1 200 OK', + 'Content-Type: text/event-stream', + 'Transfer-Encoding: chunked', + '', + "16\r\nevent: unknown\ndata: \n\r\n0\r\n\r\n" + ); + + $backend= new Channel([$response]); + $ws= new Channel([]); + $fixture= new TranslateMessages($backend); + $fixture->message(new Connection($ws, 1, null, '/ws', []), 'Test'); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\210\056\003\363Unexpected event from backend:///ws: unknown", implode('', $ws->out)); + Assert::false($ws->isConnected()); + } + #[Test] public function backend_error() { $request= $this->message( From 7139ed626a9042bfb8aed9d3ee7e0242c9a2d9ed Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 15:52:23 +0100 Subject: [PATCH 30/79] Pass websocket ID via (non-standard) header "Sec-WebSocket-Id" --- src/main/php/web/handler/WebSocket.class.php | 1 + .../php/xp/web/srv/TranslateMessages.class.php | 8 ++++++-- .../server/TranslateMessagesTest.class.php | 16 +++++++++++----- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index 4d03e5cf..b7c6e418 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -42,6 +42,7 @@ public function handle($request, $response) { $response->answer(200); $response->header('Content-Type', 'text/event-stream'); $response->header('Transfer-Encoding', 'chunked'); + $response->trace('websocket', $request->header('Sec-WebSocket-Id')); $events= new EventSink($request, $response); try { diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/TranslateMessages.class.php index 5a22fb53..4d67d67e 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/TranslateMessages.class.php @@ -30,9 +30,13 @@ public function __construct(Socket $backend) { * @return var */ public function message($conn, $message) { - $type= $message instanceof Bytes ? 'application/octet-stream' : 'text/plain'; $request= "POST {$conn->path()} HTTP/1.1\r\n"; - $headers= ['Sec-WebSocket-Version' => 9, 'Content-Type' => $type, 'Content-Length' => strlen($message)]; + $headers= [ + 'Sec-WebSocket-Version' => 9, + 'Sec-WebSocket-Id' => $conn->id(), + 'Content-Type' => $message instanceof Bytes ? 'application/octet-stream' : 'text/plain', + 'Content-Length' => strlen($message), + ]; foreach ($headers + $conn->headers() as $name => $value) { $request.= "{$name}: {$value}\r\n"; } diff --git a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php index e81414e4..e326e663 100755 --- a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php +++ b/src/test/php/web/unittest/server/TranslateMessagesTest.class.php @@ -7,6 +7,7 @@ use xp\web\srv\TranslateMessages; class TranslateMessagesTest { + const WSID= 6100; /** Creates a HTTP message */ private function message(...$lines): string { @@ -23,6 +24,7 @@ public function text() { $request= $this->message( 'POST /ws HTTP/1.1', 'Sec-WebSocket-Version: 9', + 'Sec-WebSocket-Id: '.self::WSID, 'Content-Type: text/plain', 'Content-Length: 4', '', @@ -39,7 +41,7 @@ public function text() { $backend= new Channel([$response]); $ws= new Channel([]); $fixture= new TranslateMessages($backend); - $fixture->message(new Connection($ws, 1, null, '/ws', []), 'Test'); + $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); Assert::equals("\201\006Tested", implode('', $ws->out)); @@ -51,6 +53,7 @@ public function binary() { $request= $this->message( 'POST /ws HTTP/1.1', 'Sec-WebSocket-Version: 9', + 'Sec-WebSocket-Id: '.self::WSID, 'Content-Type: application/octet-stream', 'Content-Length: 2', '', @@ -67,7 +70,7 @@ public function binary() { $backend= new Channel([$response]); $ws= new Channel([]); $fixture= new TranslateMessages($backend); - $fixture->message(new Connection($ws, 1, null, '/ws', []), new Bytes([8, 15])); + $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); Assert::equals("\202\002\047\011", implode('', $ws->out)); @@ -79,6 +82,7 @@ public function close() { $request= $this->message( 'POST /ws HTTP/1.1', 'Sec-WebSocket-Version: 9', + 'Sec-WebSocket-Id: '.self::WSID, 'Content-Type: application/octet-stream', 'Content-Length: 2', '', @@ -95,7 +99,7 @@ public function close() { $backend= new Channel([$response]); $ws= new Channel([]); $fixture= new TranslateMessages($backend); - $fixture->message(new Connection($ws, 1, null, '/ws', []), new Bytes([8, 15])); + $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); Assert::equals("\210\007\003\363Error", implode('', $ws->out)); @@ -107,6 +111,7 @@ public function unexpected_type() { $request= $this->message( 'POST /ws HTTP/1.1', 'Sec-WebSocket-Version: 9', + 'Sec-WebSocket-Id: '.self::WSID, 'Content-Type: text/plain', 'Content-Length: 4', '', @@ -123,7 +128,7 @@ public function unexpected_type() { $backend= new Channel([$response]); $ws= new Channel([]); $fixture= new TranslateMessages($backend); - $fixture->message(new Connection($ws, 1, null, '/ws', []), 'Test'); + $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); Assert::equals("\210\056\003\363Unexpected event from backend:///ws: unknown", implode('', $ws->out)); @@ -135,6 +140,7 @@ public function backend_error() { $request= $this->message( 'POST /ws HTTP/1.1', 'Sec-WebSocket-Version: 9', + 'Sec-WebSocket-Id: '.self::WSID, 'Content-Type: text/plain', 'Content-Length: 4', '', @@ -151,7 +157,7 @@ public function backend_error() { $backend= new Channel([$response]); $ws= new Channel([]); $fixture= new TranslateMessages($backend); - $fixture->message(new Connection($ws, 1, null, '/ws', []), 'Test'); + $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); Assert::equals("\210\060\003\363Unexpected status code from backend:///ws: 500", implode('', $ws->out)); From 4e47393d78d04dee7fa34ddafab189cf20080240 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 15:57:22 +0100 Subject: [PATCH 31/79] Test invoking websocket with invalid utf-8 --- src/it/php/web/unittest/IntegrationTest.class.php | 15 ++++++++++++++- src/main/php/xp/web/srv/WsProtocol.class.php | 2 +- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/it/php/web/unittest/IntegrationTest.class.php b/src/it/php/web/unittest/IntegrationTest.class.php index fe61f5a2..3096af75 100755 --- a/src/it/php/web/unittest/IntegrationTest.class.php +++ b/src/it/php/web/unittest/IntegrationTest.class.php @@ -1,6 +1,7 @@ server->connection, '/ws'); + $ws->connect(); + $ws->send("\xfc"); + $ws->receive(); + } finally { + $ws->close(); + } + } + #[After] public function shutdown() { $this->server->shutdown(); diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WsProtocol.class.php index 9b62d506..846be26b 100755 --- a/src/main/php/xp/web/srv/WsProtocol.class.php +++ b/src/main/php/xp/web/srv/WsProtocol.class.php @@ -54,7 +54,7 @@ public function handleData($socket) { switch ($opcode) { case Opcodes::TEXT: if (!preg_match('//u', $payload)) { - $conn->answer(Opcodes::CLOSE, pack('n', 1007)); + $conn->answer(Opcodes::CLOSE, pack('na*', 1007, 'Not valid utf-8')); $hints= ['error' => new FormatException('Malformed payload')]; $socket->close(); break; From f5d850475b1c6a8ad9ac64371d347a399e994356 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 17:14:08 +0100 Subject: [PATCH 32/79] Fix chunked transfer-encoding in requests and responses --- .../php/xp/web/srv/ForwardRequests.class.php | 49 ++++++-- .../server/ForwardRequestsTest.class.php | 109 ++++++++++++++++++ 2 files changed, 147 insertions(+), 11 deletions(-) create mode 100755 src/test/php/web/unittest/server/ForwardRequestsTest.class.php diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 6767d16c..edfd28d7 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -1,8 +1,13 @@ backend= $backend; } + /** + * Transmits data from an optional stream to a given target socket, + * handling chunked transfer-encoding. + * + * @see https://developer.mozilla.org/de/docs/Web/HTTP/Headers/Transfer-Encoding + * @param io.streams.InputStream $stream + * @param peer.Socket $target + * @return void + */ + private function transmit($stream, $target) { + if (null === $stream) { + // NOOP + } else if ($stream instanceof ReadChunks) { + while ($stream->available()) { + yield; + $chunk= $stream->read(); + $target->write(dechex(strlen($chunk))."\r\n".$chunk."\r\n"); + } + $target->write("0\r\n\r\n"); + } else { + while ($stream->available()) { + yield; + $target->write($stream->read()); + } + } + } + /** * Handle client data * @@ -33,13 +65,9 @@ public function handleData($socket) { } // \util\cmd\Console::writeLine('>>> ', $message); $this->backend->write($message."\r\n"); - - if ($stream= $request->incoming()) { - while ($stream->available()) { - $this->backend->write($stream->read()); - } + foreach ($this->transmit($request->incoming(), $this->backend) as $step) { + yield 'read' => $socket; } - yield 'write' => $socket; $response= new Input($this->backend); foreach ($response->consume() as $_) { } @@ -51,6 +79,7 @@ public function handleData($socket) { $result= null; } + yield 'write' => $socket; $message= "HTTP/{$response->version()} {$response->status()} {$response->message()}\r\n"; foreach ($response->headers() as $name => $value) { isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; @@ -58,10 +87,8 @@ public function handleData($socket) { // \util\cmd\Console::writeLine('<<< ', $message); $socket->write($message."\r\n"); - if ($stream= $response->incoming()) { - while ($stream->available()) { - $socket->write($stream->read()); - } + foreach ($this->transmit($response->incoming(), $socket) as $step) { + yield 'write' => $socket; } $this->backend->close(); diff --git a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php new file mode 100755 index 00000000..bec4963a --- /dev/null +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -0,0 +1,109 @@ +handleData($client) ?? [] as $_) { } + } + + #[Test] + public function can_create() { + new ForwardRequests(new Channel([])); + } + + #[Test] + public function forward_get_request() { + $request= $this->message( + 'GET / HTTP/1.0', + '', + '', + ); + $response= $this->message( + 'HTTP/1.0 200 OK', + 'Content-Length: 4', + '', + 'Test' + ); + $client= new Channel([$request]); + $backend= new Channel([$response]); + $this->forward($client, $backend); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals($response, implode('', $client->out)); + } + + #[Test] + public function forward_get_request_with_chunked_response() { + $request= $this->message( + 'GET / HTTP/1.0', + '', + '', + ); + $response= $this->message( + 'HTTP/1.0 200 OK', + 'Transfer-Encoding: chunked', + '', + "4\r\nid=2\r\n0\r\n\r\n" + ); + $client= new Channel([$request]); + $backend= new Channel([$response]); + $this->forward($client, $backend); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals($response, implode('', $client->out)); + } + + #[Test] + public function forward_post_request_with_length() { + $request= $this->message( + 'POST / HTTP/1.0', + 'Content-Length: 4', + '', + 'Test', + ); + $response= $this->message( + 'HTTP/1.0 201 Created', + 'Location: /test/1', + '', + '' + ); + $client= new Channel([$request]); + $backend= new Channel([$response]); + $this->forward($client, $backend); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals($response, implode('', $client->out)); + } + + #[Test] + public function forward_post_request_with_chunked_request() { + $request= $this->message( + 'POST / HTTP/1.0', + 'Transfer-Encoding: chunked', + '', + "4\r\nTest\r\n0\r\n\r\n", + ); + $response= $this->message( + 'HTTP/1.0 201 Created', + 'Location: /test/1', + '', + '' + ); + $client= new Channel([$request]); + $backend= new Channel([$response]); + $this->forward($client, $backend); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals($response, implode('', $client->out)); + } +} \ No newline at end of file From d4c3b594495080397ccd5bd8ca1ac7e7eefb717a Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 17:31:34 +0100 Subject: [PATCH 33/79] Make ReadLength consistent with ReadChunks on EOF handling --- src/main/php/web/io/ReadLength.class.php | 2 ++ .../php/web/unittest/io/ReadLengthTest.class.php | 15 ++++++++++----- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/php/web/io/ReadLength.class.php b/src/main/php/web/io/ReadLength.class.php index fe182915..2ad1042d 100755 --- a/src/main/php/web/io/ReadLength.class.php +++ b/src/main/php/web/io/ReadLength.class.php @@ -29,6 +29,8 @@ public function __construct(Input $input, $length) { * @return string */ public function read($limit= 8192) { + if (0 === $this->remaininig) return ''; + $chunk= $this->input->read(min($limit, $this->remaininig)); if ('' === $chunk) { $this->remaininig= 0; diff --git a/src/test/php/web/unittest/io/ReadLengthTest.class.php b/src/test/php/web/unittest/io/ReadLengthTest.class.php index cf1c313a..c1aaa60a 100755 --- a/src/test/php/web/unittest/io/ReadLengthTest.class.php +++ b/src/test/php/web/unittest/io/ReadLengthTest.class.php @@ -46,15 +46,20 @@ public function available_after_read() { Assert::equals(0, $fixture->available()); } + #[Test] + public function raises_exception_on_eof_before_length_reached() { + $fixture= new ReadLength($this->input('Test'), 10); + $fixture->read(); + + Assert::throws(IOException::class, fn() => $fixture->read(1)); + } + #[Test, Values([4, 8192])] - public function reading_after_eof_raises_exception($length) { + public function reading_after_eof_returns_empty_string($length) { $fixture= new ReadLength($this->input('Test'), 4); $fixture->read($length); - try { - $fixture->read(1); - $this->fail('No exception raised', null, IOException::class); - } catch (IOException $expected) { } + Assert::equals('', $fixture->read(1)); } #[Test] From d3350b90b877039cc5fba775615957ffc74e5757 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 17:36:26 +0100 Subject: [PATCH 34/79] Make compatible with xp-forge/uri 2.0-RELEASE --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index fee7a849..5abb2743 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require" : { "xp-framework/core": "^12.0 | ^11.0 | ^10.0", "xp-framework/networking": "^10.1", - "xp-forge/uri": "^3.0", + "xp-forge/uri": "^3.0 | ^2.0", "xp-forge/websockets": "^4.1", "php": ">=7.4.0" }, From b9c69dc7f4344d281958d042ccf53b664408090a Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 17:44:40 +0100 Subject: [PATCH 35/79] Stacktrace appears in hints, omit printing to STDERR --- src/main/php/xp/web/srv/HttpProtocol.class.php | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index e1856f5c..adad31ed 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -34,9 +34,7 @@ public function __construct($application, $logging) { * @return void */ private function sendError($request, $response, $error) { - if ($response->flushed()) { - $error->printStackTrace(); - } else { + if (!$response->flushed()) { $loader= ClassLoader::getDefault(); $message= Status::message($error->status()); From 9f9fe9f60973d8186f3883e99d1046adc8631e68 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 18 Jan 2025 17:59:07 +0100 Subject: [PATCH 36/79] Forward synchronously, the PHP development webserver can only handle one at a time! --- src/main/php/xp/web/srv/ForwardRequests.class.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index edfd28d7..15b0a861 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -66,7 +66,7 @@ public function handleData($socket) { // \util\cmd\Console::writeLine('>>> ', $message); $this->backend->write($message."\r\n"); foreach ($this->transmit($request->incoming(), $this->backend) as $step) { - yield 'read' => $socket; + // yield 'read' => $socket; } $response= new Input($this->backend); @@ -79,7 +79,7 @@ public function handleData($socket) { $result= null; } - yield 'write' => $socket; + // yield 'write' => $socket; $message= "HTTP/{$response->version()} {$response->status()} {$response->message()}\r\n"; foreach ($response->headers() as $name => $value) { isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; @@ -88,7 +88,7 @@ public function handleData($socket) { $socket->write($message."\r\n"); foreach ($this->transmit($response->incoming(), $socket) as $step) { - yield 'write' => $socket; + // yield 'write' => $socket; } $this->backend->close(); From 470db1c3f9da732050f3b7f960992e4aa92da21b Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 11:27:55 +0100 Subject: [PATCH 37/79] Simplify code by using the URI::resource() accessor See https://github.com/xp-forge/web/pull/121#discussion_r1921520309 --- composer.json | 2 +- src/main/php/web/Logging.class.php | 13 ++++++------- src/main/php/web/handler/WebSocket.class.php | 6 +----- src/main/php/web/io/EventSink.class.php | 8 +------- 4 files changed, 9 insertions(+), 20 deletions(-) diff --git a/composer.json b/composer.json index 5abb2743..43f69a3f 100755 --- a/composer.json +++ b/composer.json @@ -8,7 +8,7 @@ "require" : { "xp-framework/core": "^12.0 | ^11.0 | ^10.0", "xp-framework/networking": "^10.1", - "xp-forge/uri": "^3.0 | ^2.0", + "xp-forge/uri": "^3.1", "xp-forge/websockets": "^4.1", "php": ">=7.4.0" }, diff --git a/src/main/php/web/Logging.class.php b/src/main/php/web/Logging.class.php index 4ad3a18b..2c8a7b22 100755 --- a/src/main/php/web/Logging.class.php +++ b/src/main/php/web/Logging.class.php @@ -66,13 +66,12 @@ public function tee($sink) { * @return void */ public function exchange($request, $response, $hints= []) { - if (!$this->sink) return; - - $uri= $request->uri()->path(); - if ($query= $request->uri()->query()) { - $uri.= '?'.$query; - } - $this->sink->log($response->status(), $request->method(), $uri, $response->trace + $hints); + $this->sink && $this->sink->log( + $response->status(), + $request->method(), + $request->uri()->resource(), + $response->trace + $hints + ); } /** diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index b7c6e418..4476c075 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -65,12 +65,8 @@ public function handle($request, $response) { return; } - $path= $request->uri()->path(); - if ($query= $request->uri()->query()) { - $path.= '?'.$query; - } yield 'connection' => ['websocket', [ - 'path' => $path, + 'path' => $request->uri()->resource(), 'headers' => $request->headers(), 'listener' => $this->listener, ]]; diff --git a/src/main/php/web/io/EventSink.class.php b/src/main/php/web/io/EventSink.class.php index dc1acda8..c04e61a3 100755 --- a/src/main/php/web/io/EventSink.class.php +++ b/src/main/php/web/io/EventSink.class.php @@ -11,13 +11,7 @@ class EventSink extends Connection { public function __construct($request, $response) { $this->request= $request; $this->out= $response->stream(); - - $path= $request->uri()->path(); - if ($query= $request->uri()->query()) { - $path.= '?'.$query; - } - - parent::__construct(null, null, null, $path, $request->headers()); + parent::__construct(null, null, null, $request->uri()->resource(), $request->headers()); } public function receive() { From 98cdcdfb8bc365654ec48bb377805e6559b7e881 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 11:56:10 +0100 Subject: [PATCH 38/79] Prevent double-flush --- src/main/php/web/handler/WebSocket.class.php | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/src/main/php/web/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php index 4476c075..8d3cdb1a 100755 --- a/src/main/php/web/handler/WebSocket.class.php +++ b/src/main/php/web/handler/WebSocket.class.php @@ -45,12 +45,8 @@ public function handle($request, $response) { $response->trace('websocket', $request->header('Sec-WebSocket-Id')); $events= new EventSink($request, $response); - try { - foreach ($events->receive() as $message) { - $this->listener->message($events, $message); - } - } finally { - $events->flush(); + foreach ($events->receive() as $message) { + $this->listener->message($events, $message); } return; From 3e4a6b0c9251fb940f2239711eb5971fee408ef2 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 11:56:31 +0100 Subject: [PATCH 39/79] Test translating text and binary messages to SSE --- .../unittest/handler/WebSocketTest.class.php | 36 ++++++++++++++++++- 1 file changed, 35 insertions(+), 1 deletion(-) diff --git a/src/test/php/web/unittest/handler/WebSocketTest.class.php b/src/test/php/web/unittest/handler/WebSocketTest.class.php index 29400d5a..392ec832 100755 --- a/src/test/php/web/unittest/handler/WebSocketTest.class.php +++ b/src/test/php/web/unittest/handler/WebSocketTest.class.php @@ -1,6 +1,7 @@ handle($request, $response)->next(); + $echo= function($conn, $payload) { + if ($payload instanceof Bytes) { + $conn->send(new Bytes("\x08\x15{$payload}")); + } else { + $conn->send('Re: '.$payload); + } + }; + (new WebSocket($echo))->handle($request, $response)->next(); return $response; } @@ -29,6 +37,32 @@ public function switching_protocols() { Assert::equals('tNpbgC8ZQDOcSkHAWopKzQjJ1hI=', $response->headers()['Sec-WebSocket-Accept']); } + #[Test] + public function translate_text_message() { + $response= $this->handle(new Request(new TestInput('POST', '/ws', [ + 'Sec-WebSocket-Version' => 9, + 'Sec-WebSocket-Id' => 123, + 'Content-Type' => 'text/plain', + 'Content-Length' => 4, + ], 'Test'))); + Assert::equals(200, $response->status()); + Assert::equals('text/event-stream', $response->headers()['Content-Type']); + Assert::matches('/10\r\ndata: Re: Test\n/', $response->output()->bytes()); + } + + #[Test] + public function translate_binary_message() { + $response= $this->handle(new Request(new TestInput('POST', '/ws', [ + 'Sec-WebSocket-Version' => 9, + 'Sec-WebSocket-Id' => 123, + 'Content-Type' => 'application/octet-stream', + 'Content-Length' => 2, + ], "\x47\x11"))); + Assert::equals(200, $response->status()); + Assert::equals('text/event-stream', $response->headers()['Content-Type']); + Assert::matches('/19\r\nevent: bytes\ndata: .{4}\n/', $response->output()->bytes()); + } + #[Test] public function non_websocket_request() { $response= $this->handle(new Request(new TestInput('GET', '/ws'))); From 7edfaca6717eeeee2453ed9c7c7b7b18f4fe24df Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 11:56:44 +0100 Subject: [PATCH 40/79] Show backend port during server startup --- src/main/php/xp/web/srv/Develop.class.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index bd8529c2..acd6a3a5 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -86,13 +86,14 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo Console::writeLinef( "\e[33;1m>\e[0m Server started: \e[35;4mhttp://%s:%d/\e[0m in %.3f seconds\n". - " %s - PID %d & %d; press Enter to exit\n", + " %s - PID %d -> %d @ :%d; press Enter to exit\n", '0.0.0.0' === $this->host ? '127.0.0.1' : $this->host, $this->port, microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], date('r'), getmypid(), - proc_get_status($proc)['pid'] + proc_get_status($proc)['pid'], + $matches[2], ); // Start the multiplex protocol in the foreground and forward requests From 9d0f80ec4d10f34b47255ae6714323a848fcf563 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 11:59:52 +0100 Subject: [PATCH 41/79] Rename WsProtocol -> WebsocketProtocol for naming consistency --- src/main/php/xp/web/srv/Develop.class.php | 2 +- src/main/php/xp/web/srv/Standalone.class.php | 2 +- ...sProtocol.class.php => WebsocketProtocol.class.php} | 10 +++++----- 3 files changed, 7 insertions(+), 7 deletions(-) rename src/main/php/xp/web/srv/{WsProtocol.class.php => WebsocketProtocol.class.php} (96%) diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index acd6a3a5..04eadef5 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -100,7 +100,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() ->serving('http', new ForwardRequests($backend)) - ->serving('websocket', new WsProtocol(Logging::of(null), new TranslateMessages($backend))) + ->serving('websocket', new WebsocketProtocol(new TranslateMessages($backend), Logging::of(null))) ); $impl->init(); $impl->service(); diff --git a/src/main/php/xp/web/srv/Standalone.class.php b/src/main/php/xp/web/srv/Standalone.class.php index 2321498d..6b0df557 100755 --- a/src/main/php/xp/web/srv/Standalone.class.php +++ b/src/main/php/xp/web/srv/Standalone.class.php @@ -62,7 +62,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $socket= new ServerSocket($this->host, $this->port); $this->impl->listen($socket, Protocol::multiplex() ->serving('http', new HttpProtocol($application, $environment->logging())) - ->serving('websocket', new WsProtocol($environment->logging())) + ->serving('websocket', new WebsocketProtocol(null, $environment->logging())) ); $this->impl->init(); diff --git a/src/main/php/xp/web/srv/WsProtocol.class.php b/src/main/php/xp/web/srv/WebsocketProtocol.class.php similarity index 96% rename from src/main/php/xp/web/srv/WsProtocol.class.php rename to src/main/php/xp/web/srv/WebsocketProtocol.class.php index 846be26b..7675af0b 100755 --- a/src/main/php/xp/web/srv/WsProtocol.class.php +++ b/src/main/php/xp/web/srv/WebsocketProtocol.class.php @@ -5,19 +5,19 @@ use util\Bytes; use websocket\protocol\{Opcodes, Connection}; -class WsProtocol extends Switchable { - private $logging, $listener; +class WebsocketProtocol extends Switchable { + private $listener, $logging; private $connections= []; /** * Creates a new protocol instance * - * @param web.Logging $logging * @param ?websocket.Listener $listener + * @param web.Logging $logging */ - public function __construct($logging, $listener= null) { - $this->logging= $logging; + public function __construct($listener, $logging) { $this->listener= $listener; + $this->logging= $logging; } /** From 170e39bed96d423cc33ed8d16bb086b1b16afeab Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 12:12:56 +0100 Subject: [PATCH 42/79] Add tests for web.io.EventSink --- src/main/php/web/io/EventSink.class.php | 29 ++++++- .../web/unittest/io/EventSinkTest.class.php | 77 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) create mode 100755 src/test/php/web/unittest/io/EventSinkTest.class.php diff --git a/src/main/php/web/io/EventSink.class.php b/src/main/php/web/io/EventSink.class.php index c04e61a3..f9ec4a2b 100755 --- a/src/main/php/web/io/EventSink.class.php +++ b/src/main/php/web/io/EventSink.class.php @@ -5,15 +5,27 @@ use util\Bytes; use websocket\protocol\{Opcodes, Connection}; +/** @test web.unittest.io.EventSinkTest */ class EventSink extends Connection { private $request, $out; + /** + * Creates a new event sink + * + * @param web.Request $request + * @param web.Response $response + */ public function __construct($request, $response) { $this->request= $request; $this->out= $response->stream(); parent::__construct(null, null, null, $request->uri()->resource(), $request->headers()); } + /** + * Receives messages + * + * @return iterable + */ public function receive() { switch ($mime= $this->request->header('Content-Type')) { case 'text/plain': yield Opcodes::TEXT => Streams::readAll($this->request->stream()); break; @@ -22,6 +34,12 @@ public function receive() { } } + /** + * Sends a websocket message + * + * @param string|util.Bytes $message + * @return void + */ public function send($message) { if ($message instanceof Bytes) { $this->out->write("event: bytes\ndata: ".addcslashes($message, "\r\n")."\n\n"); @@ -30,11 +48,14 @@ public function send($message) { } } + /** + * Closes the websocket connection + * + * @param int $code + * @param string $reason + * @return void + */ public function close($code= 1000, $reason= '') { $this->out->write("event: close\ndata: ".$code.':'.addcslashes($reason, "\r\n")."\n\n"); } - - public function flush() { - $this->out->finish(); - } } \ No newline at end of file diff --git a/src/test/php/web/unittest/io/EventSinkTest.class.php b/src/test/php/web/unittest/io/EventSinkTest.class.php new file mode 100755 index 00000000..0959542e --- /dev/null +++ b/src/test/php/web/unittest/io/EventSinkTest.class.php @@ -0,0 +1,77 @@ + 9, + 'Sec-WebSocket-Id' => 123, + 'Content-Type' => 'text/plain', + 'Content-Length' => 4, + ], 'Test')); + + Assert::equals( + [Opcodes::TEXT => 'Test'], + [...(new EventSink($request, new Response(new TestOutput())))->receive()] + ); + } + + #[Test] + public function receive_binary_message() { + $request= new Request(new TestInput('POST', '/ws', [ + 'Sec-WebSocket-Version' => 9, + 'Sec-WebSocket-Id' => 123, + 'Content-Type' => 'application/octet-stream', + 'Content-Length' => 2, + ], "\x47\x11")); + + Assert::equals( + [Opcodes::BINARY => new Bytes("\x47\x11")], + [...(new EventSink($request, new Response(new TestOutput())))->receive()] + ); + } + + #[Test] + public function send_text_message() { + $response= new Response(new TestOutput()); + (new EventSink(new Request(new TestInput('POST', '/ws')), $response))->send('Test'); + + Assert::matches('/data: Test\n/', $response->output()->bytes()); + } + + #[Test] + public function send_binary_message() { + $response= new Response(new TestOutput()); + (new EventSink(new Request(new TestInput('POST', '/ws')), $response))->send(new Bytes("\x47\x11")); + + Assert::matches('/event: bytes\ndata: .{2}\n/', $response->output()->bytes()); + } + + #[Test] + public function close_message() { + $response= new Response(new TestOutput()); + (new EventSink(new Request(new TestInput('POST', '/ws')), $response))->close(); + + Assert::matches('/event: close\ndata: 1000:\n/', $response->output()->bytes()); + } + + #[Test] + public function close_message_with_reason() { + $response= new Response(new TestOutput()); + (new EventSink(new Request(new TestInput('POST', '/ws')), $response))->close(1011, 'Test'); + + Assert::matches('/event: close\ndata: 1011:Test\n/', $response->output()->bytes()); + } +} \ No newline at end of file From f409f0cf0217ec17f07c8b1d6ec7cce522ce9170 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 12:33:29 +0100 Subject: [PATCH 43/79] Rename TranslateMessages -> ForwardMessages --- src/main/php/xp/web/srv/Develop.class.php | 2 +- ...sages.class.php => ForwardMessages.class.php} | 6 +++--- .../php/xp/web/srv/WebsocketProtocol.class.php | 7 ++++--- ...t.class.php => ForwardMessagesTest.class.php} | 16 ++++++++-------- 4 files changed, 16 insertions(+), 15 deletions(-) rename src/main/php/xp/web/srv/{TranslateMessages.class.php => ForwardMessages.class.php} (93%) rename src/test/php/web/unittest/server/{TranslateMessagesTest.class.php => ForwardMessagesTest.class.php} (92%) diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 04eadef5..72f2e4d8 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -100,7 +100,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() ->serving('http', new ForwardRequests($backend)) - ->serving('websocket', new WebsocketProtocol(new TranslateMessages($backend), Logging::of(null))) + ->serving('websocket', new WebsocketProtocol(new ForwardMessages($backend))) ); $impl->init(); $impl->service(); diff --git a/src/main/php/xp/web/srv/TranslateMessages.class.php b/src/main/php/xp/web/srv/ForwardMessages.class.php similarity index 93% rename from src/main/php/xp/web/srv/TranslateMessages.class.php rename to src/main/php/xp/web/srv/ForwardMessages.class.php index 4d67d67e..2ec98cdb 100755 --- a/src/main/php/xp/web/srv/TranslateMessages.class.php +++ b/src/main/php/xp/web/srv/ForwardMessages.class.php @@ -9,12 +9,12 @@ use websocket\protocol\Opcodes; /** - * Translates websocket messages into HTTP requests to an SSE endpoint + * Forwards websocket messages to an HTTP SSE endpoint. * - * @test web.unittest.server.TranslateMessagesTest + * @test web.unittest.server.ForwardMessagesTest * @see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events */ -class TranslateMessages extends Listener { +class ForwardMessages extends Listener { private $backend; /** Creates a new instance */ diff --git a/src/main/php/xp/web/srv/WebsocketProtocol.class.php b/src/main/php/xp/web/srv/WebsocketProtocol.class.php index 7675af0b..305e8f03 100755 --- a/src/main/php/xp/web/srv/WebsocketProtocol.class.php +++ b/src/main/php/xp/web/srv/WebsocketProtocol.class.php @@ -3,6 +3,7 @@ use Throwable as Any; use lang\{Throwable, FormatException}; use util\Bytes; +use web\Logging; use websocket\protocol\{Opcodes, Connection}; class WebsocketProtocol extends Switchable { @@ -13,11 +14,11 @@ class WebsocketProtocol extends Switchable { * Creates a new protocol instance * * @param ?websocket.Listener $listener - * @param web.Logging $logging + * @param ?web.Logging $logging */ - public function __construct($listener, $logging) { + public function __construct($listener, $logging= null) { $this->listener= $listener; - $this->logging= $logging; + $this->logging= $logging ?? new Logging(null); } /** diff --git a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php similarity index 92% rename from src/test/php/web/unittest/server/TranslateMessagesTest.class.php rename to src/test/php/web/unittest/server/ForwardMessagesTest.class.php index e326e663..3fcf4eef 100755 --- a/src/test/php/web/unittest/server/TranslateMessagesTest.class.php +++ b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php @@ -4,9 +4,9 @@ use util\Bytes; use web\unittest\Channel; use websocket\protocol\Connection; -use xp\web\srv\TranslateMessages; +use xp\web\srv\ForwardMessages; -class TranslateMessagesTest { +class ForwardMessagesTest { const WSID= 6100; /** Creates a HTTP message */ @@ -16,7 +16,7 @@ private function message(...$lines): string { #[Test] public function can_create() { - new TranslateMessages(new Channel([])); + new ForwardMessages(new Channel([])); } #[Test] @@ -40,7 +40,7 @@ public function text() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new TranslateMessages($backend); + $fixture= new ForwardMessages($backend); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); @@ -69,7 +69,7 @@ public function binary() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new TranslateMessages($backend); + $fixture= new ForwardMessages($backend); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); @@ -98,7 +98,7 @@ public function close() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new TranslateMessages($backend); + $fixture= new ForwardMessages($backend); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); @@ -127,7 +127,7 @@ public function unexpected_type() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new TranslateMessages($backend); + $fixture= new ForwardMessages($backend); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); @@ -156,7 +156,7 @@ public function backend_error() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new TranslateMessages($backend); + $fixture= new ForwardMessages($backend); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); From ee3a34bff702a212252af25870bd99d4ef0339c6 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 12:34:40 +0100 Subject: [PATCH 44/79] Consistently use "resource" instead of "uri" in logging API --- src/main/php/web/Logging.class.php | 8 ++++---- src/main/php/web/logging/Sink.class.php | 4 ++-- src/main/php/web/logging/ToAllOf.class.php | 6 +++--- src/main/php/web/logging/ToCategory.class.php | 8 ++++---- src/main/php/web/logging/ToConsole.class.php | 6 +++--- src/main/php/web/logging/ToFile.class.php | 6 +++--- src/main/php/web/logging/ToFunction.class.php | 6 +++--- 7 files changed, 22 insertions(+), 22 deletions(-) diff --git a/src/main/php/web/Logging.class.php b/src/main/php/web/Logging.class.php index 2c8a7b22..67baa300 100755 --- a/src/main/php/web/Logging.class.php +++ b/src/main/php/web/Logging.class.php @@ -58,7 +58,7 @@ public function tee($sink) { } /** - * Writes a HTTP exchange to the log + * Writes an HTTP exchange to the log * * @param web.Request $response * @param web.Response $response @@ -79,12 +79,12 @@ public function exchange($request, $response, $hints= []) { * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public function log($status, $method, $uri, $hints= []) { - $this->sink && $this->sink->log($status, $method, $uri, $hints); + public function log($status, $method, $resource, $hints= []) { + $this->sink && $this->sink->log($status, $method, $resource, $hints); } /** diff --git a/src/main/php/web/logging/Sink.class.php b/src/main/php/web/logging/Sink.class.php index 9f925f0b..bfc74751 100755 --- a/src/main/php/web/logging/Sink.class.php +++ b/src/main/php/web/logging/Sink.class.php @@ -14,11 +14,11 @@ abstract class Sink { * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public abstract function log($status, $method, $uri, $hints); + public abstract function log($status, $method, $resource, $hints); /** @return string */ public function target() { return nameof($this); } diff --git a/src/main/php/web/logging/ToAllOf.class.php b/src/main/php/web/logging/ToAllOf.class.php index 3487a22f..80b4f2b4 100755 --- a/src/main/php/web/logging/ToAllOf.class.php +++ b/src/main/php/web/logging/ToAllOf.class.php @@ -42,13 +42,13 @@ public function target() { * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public function log($status, $method, $uri, $hints) { + public function log($status, $method, $resource, $hints) { foreach ($this->sinks as $sink) { - $sink->log($status, $method, $uri, $hints); + $sink->log($status, $method, $resource, $hints); } } } \ No newline at end of file diff --git a/src/main/php/web/logging/ToCategory.class.php b/src/main/php/web/logging/ToCategory.class.php index e99a4243..d6ecf137 100755 --- a/src/main/php/web/logging/ToCategory.class.php +++ b/src/main/php/web/logging/ToCategory.class.php @@ -16,15 +16,15 @@ public function target() { return nameof($this).'('.$this->cat->toString().')'; * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public function log($status, $method, $uri, $hints) { + public function log($status, $method, $resource, $hints) { if ($hints) { - $this->cat->warn($status, $method, $uri, $hints); + $this->cat->warn($status, $method, $resource, $hints); } else { - $this->cat->info($status, $method, $uri); + $this->cat->info($status, $method, $resource); } } } \ No newline at end of file diff --git a/src/main/php/web/logging/ToConsole.class.php b/src/main/php/web/logging/ToConsole.class.php index 8ea3e20d..a543b1ad 100755 --- a/src/main/php/web/logging/ToConsole.class.php +++ b/src/main/php/web/logging/ToConsole.class.php @@ -10,11 +10,11 @@ class ToConsole extends Sink { * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public function log($status, $method, $uri, $hints) { + public function log($status, $method, $resource, $hints) { $hint= ''; foreach ($hints as $kind => $value) { $hint.= ', '.$kind.': '.(is_string($value) ? $value : Objects::stringOf($value)); @@ -27,7 +27,7 @@ public function log($status, $method, $uri, $hints) { memory_get_usage() / 1024, $status, $method, - $uri, + $resource, $hint ? " \e[2m[".substr($hint, 2)."]\e[0m" : '' ); } diff --git a/src/main/php/web/logging/ToFile.class.php b/src/main/php/web/logging/ToFile.class.php index 4c59b274..d5b4d34f 100755 --- a/src/main/php/web/logging/ToFile.class.php +++ b/src/main/php/web/logging/ToFile.class.php @@ -30,11 +30,11 @@ public function target() { return nameof($this).'('.$this->file.')'; } * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public function log($status, $method, $uri, $hints) { + public function log($status, $method, $resource, $hints) { $hint= ''; foreach ($hints as $kind => $value) { $hint.= ', '.$kind.': '.(is_string($value) ? $value : Objects::stringOf($value)); @@ -47,7 +47,7 @@ public function log($status, $method, $uri, $hints) { memory_get_usage() / 1024, $status, $method, - $uri, + $resource, $hint ? ' ['.substr($hint, 2).']' : '' ); file_put_contents($this->file, $line, FILE_APPEND | LOCK_EX); diff --git a/src/main/php/web/logging/ToFunction.class.php b/src/main/php/web/logging/ToFunction.class.php index 1f41c1b9..70e3a2f7 100755 --- a/src/main/php/web/logging/ToFunction.class.php +++ b/src/main/php/web/logging/ToFunction.class.php @@ -13,11 +13,11 @@ public function __construct($function) { * * @param string $status * @param string $method - * @param string $uri + * @param string $resource * @param [:var] $hints Optional hints * @return void */ - public function log($status, $method, $uri, $hints) { - $this->function->__invoke($status, $method, $uri, $hints); + public function log($status, $method, $resource, $hints) { + $this->function->__invoke($status, $method, $resource, $hints); } } \ No newline at end of file From 9d2bd54e44beb4e8fb26f420dfab7d2411faee52 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 12:42:09 +0100 Subject: [PATCH 45/79] Test implicit and explicit text messages --- src/main/php/xp/web/srv/ForwardMessages.class.php | 2 +- .../php/web/unittest/server/ForwardMessagesTest.class.php | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/src/main/php/xp/web/srv/ForwardMessages.class.php b/src/main/php/xp/web/srv/ForwardMessages.class.php index 2ec98cdb..eeb11e8e 100755 --- a/src/main/php/xp/web/srv/ForwardMessages.class.php +++ b/src/main/php/xp/web/srv/ForwardMessages.class.php @@ -56,7 +56,7 @@ public function message($conn, $message) { foreach (new EventSource($response->incoming()) as $event => $data) { $value= strtr($data, ['\r' => "\r", '\n' => "\n"]); switch ($event) { - case null: case 'text': $conn->send($value); break; + case 'text': case null: $conn->send($value); break; case 'bytes': $conn->send(new Bytes($value)); break; case 'close': { sscanf($value, "%d:%[^\r]", $code, $reason); diff --git a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php index 3fcf4eef..07eef8e1 100755 --- a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php +++ b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php @@ -1,6 +1,6 @@ message( 'POST /ws HTTP/1.1', 'Sec-WebSocket-Version: 9', @@ -35,7 +35,7 @@ public function text() { 'Content-Type: text/event-stream', 'Transfer-Encoding: chunked', '', - "d\r\ndata: Tested\n\r\n0\r\n\r\n" + $payload ); $backend= new Channel([$response]); From 9d9794075456341d87505e5cbe376df69785c665 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 12:59:59 +0100 Subject: [PATCH 46/79] Fix integration tests --- src/it/php/web/unittest/TestingServer.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/it/php/web/unittest/TestingServer.class.php b/src/it/php/web/unittest/TestingServer.class.php index a0b31ad6..da4cbf3c 100755 --- a/src/it/php/web/unittest/TestingServer.class.php +++ b/src/it/php/web/unittest/TestingServer.class.php @@ -5,7 +5,7 @@ use peer\server\AsyncServer; use util\cmd\Console; use web\{Environment, Logging}; -use xp\web\srv\{Protocol, HttpProtocol, WsProtocol}; +use xp\web\srv\{Protocol, HttpProtocol, WebsocketProtocol}; /** * Socket server used by integration tests. @@ -29,7 +29,7 @@ public static function main(array $args) { try { $s->listen($socket, Protocol::multiplex() ->serving('http', new HttpProtocol($application, $log)) - ->serving('websocket', new WsProtocol($log)) + ->serving('websocket', new WebsocketProtocol(null, $log)) ); $s->init(); Console::writeLinef('+ Service %s:%d', $socket->host, $socket->port); From e24aedd960d4e0a27efee2d9586b9f4744a7eaf9 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 13:01:30 +0100 Subject: [PATCH 47/79] QA: Apidoc types --- src/main/php/xp/web/srv/ForwardRequests.class.php | 4 ++-- src/main/php/xp/web/srv/Protocol.class.php | 5 ++++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 15b0a861..4d8c0ac8 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -23,7 +23,7 @@ public function __construct(Socket $backend) { * @see https://developer.mozilla.org/de/docs/Web/HTTP/Headers/Transfer-Encoding * @param io.streams.InputStream $stream * @param peer.Socket $target - * @return void + * @return iterable */ private function transmit($stream, $target) { if (null === $stream) { @@ -47,7 +47,7 @@ private function transmit($stream, $target) { * Handle client data * * @param peer.Socket $socket - * @return void + * @return iterable */ public function handleData($socket) { static $exclude= ['Remote-Addr' => true]; diff --git a/src/main/php/xp/web/srv/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php index 536aeb97..2907c24e 100755 --- a/src/main/php/xp/web/srv/Protocol.class.php +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -46,6 +46,7 @@ public function initialize() { * Handle client connect * * @param peer.Socket $socket + * @return void */ public function handleConnect($socket) { $this->protocols[spl_object_id($socket)]= current($this->protocols); @@ -55,7 +56,7 @@ public function handleConnect($socket) { * Handle client data * * @param peer.Socket $socket - * @return void + * @return iterable */ public function handleData($socket) { $handle= spl_object_id($socket); @@ -77,6 +78,7 @@ public function handleData($socket) { * Handle client disconnect * * @param peer.Socket $socket + * @return void */ public function handleDisconnect($socket) { $handle= spl_object_id($socket); @@ -92,6 +94,7 @@ public function handleDisconnect($socket) { * * @param peer.Socket $socket * @param lang.XPException $e + * @return void */ public function handleError($socket, $e) { $handle= spl_object_id($socket); From 3ba8c75baf38b9a0318f131043ee289a98a7d0b6 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 13:03:06 +0100 Subject: [PATCH 48/79] Fix "Cannot unpack Traversable with string keys" (PHP 7.4, 8.0) --- src/test/php/web/unittest/io/EventSinkTest.class.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/test/php/web/unittest/io/EventSinkTest.class.php b/src/test/php/web/unittest/io/EventSinkTest.class.php index 0959542e..2c518c1a 100755 --- a/src/test/php/web/unittest/io/EventSinkTest.class.php +++ b/src/test/php/web/unittest/io/EventSinkTest.class.php @@ -24,7 +24,7 @@ public function receive_text_message() { Assert::equals( [Opcodes::TEXT => 'Test'], - [...(new EventSink($request, new Response(new TestOutput())))->receive()] + iterator_to_array((new EventSink($request, new Response(new TestOutput())))->receive()) ); } @@ -39,7 +39,7 @@ public function receive_binary_message() { Assert::equals( [Opcodes::BINARY => new Bytes("\x47\x11")], - [...(new EventSink($request, new Response(new TestOutput())))->receive()] + iterator_to_array((new EventSink($request, new Response(new TestOutput())))->receive()) ); } From 1460267dc17794d6735536e7290e32e678c58076 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 13:08:52 +0100 Subject: [PATCH 49/79] Rename "uri" to "resource" in web.io.Input Consistent with naming in util.URI class, see xp-forge/uri#10 --- src/main/php/web/Request.class.php | 2 +- src/main/php/web/io/Input.class.php | 4 ++-- src/main/php/web/io/TestInput.class.php | 10 +++++----- src/main/php/xp/web/SAPI.class.php | 2 +- src/main/php/xp/web/srv/ForwardRequests.class.php | 4 ++-- src/main/php/xp/web/srv/Input.class.php | 6 +++--- src/test/php/web/unittest/io/TestInputTest.class.php | 6 +++--- src/test/php/web/unittest/server/InputTest.class.php | 11 ++++++++--- src/test/php/web/unittest/server/SAPITest.class.php | 4 ++-- 9 files changed, 27 insertions(+), 22 deletions(-) diff --git a/src/main/php/web/Request.class.php b/src/main/php/web/Request.class.php index 027cd0a4..f4ba2d03 100755 --- a/src/main/php/web/Request.class.php +++ b/src/main/php/web/Request.class.php @@ -29,7 +29,7 @@ public function __construct(Input $input) { } $this->method= $input->method(); - $this->uri= (new URI($input->scheme().'://'.$this->header('Host', 'localhost').$input->uri()))->canonicalize(); + $this->uri= (new URI($input->scheme().'://'.$this->header('Host', 'localhost').$input->resource()))->canonicalize(); $this->input= $input; } diff --git a/src/main/php/web/io/Input.class.php b/src/main/php/web/io/Input.class.php index 2e5b30b0..7db8f2d6 100755 --- a/src/main/php/web/io/Input.class.php +++ b/src/main/php/web/io/Input.class.php @@ -11,8 +11,8 @@ public function method(); /** @return string */ public function scheme(); - /** @return sring */ - public function uri(); + /** @return string */ + public function resource(); /** @return iterable */ public function headers(); diff --git a/src/main/php/web/io/TestInput.class.php b/src/main/php/web/io/TestInput.class.php index b39ab05a..1496c7b9 100755 --- a/src/main/php/web/io/TestInput.class.php +++ b/src/main/php/web/io/TestInput.class.php @@ -6,20 +6,20 @@ * @test xp://web.unittest.io.TestInputTest */ class TestInput implements Input { - private $method, $uri, $headers, $body; + private $method, $resource, $headers, $body; private $incoming= null; /** * Creates a new instance * * @param string $method - * @param string $uri + * @param string $resource * @param [:string] $headers * @param string|[:string] $body */ - public function __construct($method, $uri, $headers= [], $body= '') { + public function __construct($method, $resource, $headers= [], $body= '') { $this->method= $method; - $this->uri= $uri; + $this->resource= $resource; $this->headers= $headers; if (is_array($body)) { @@ -45,7 +45,7 @@ public function scheme() { return 'http'; } public function method() { return $this->method; } /** @return string */ - public function uri() { return $this->uri; } + public function resource() { return $this->resource; } /** @return iterable */ public function headers() { return $this->headers; } diff --git a/src/main/php/xp/web/SAPI.class.php b/src/main/php/xp/web/SAPI.class.php index 19746e7d..9af472f7 100755 --- a/src/main/php/xp/web/SAPI.class.php +++ b/src/main/php/xp/web/SAPI.class.php @@ -57,7 +57,7 @@ public function version() { } /** @return string */ - public function uri() { return $_SERVER['REQUEST_URI']; } + public function resource() { return $_SERVER['REQUEST_URI']; } /** @return [:string] */ public function headers() { diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 4d8c0ac8..5c7ba388 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -57,7 +57,7 @@ public function handleData($socket) { if (Input::REQUEST === $request->kind) { $this->backend->connect(); - $message= "{$request->method()} {$request->uri()} HTTP/{$request->version()}\r\n"; + $message= "{$request->method()} {$request->resource()} HTTP/{$request->version()}\r\n"; $headers= []; foreach ($request->headers() as $name => $value) { isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; @@ -74,7 +74,7 @@ public function handleData($socket) { // Switch protocols if (101 === $response->status()) { - $result= ['websocket', ['path' => $request->uri(), 'headers' => $headers]]; + $result= ['websocket', ['path' => $request->resource(), 'headers' => $headers]]; } else { $result= null; } diff --git a/src/main/php/xp/web/srv/Input.class.php b/src/main/php/xp/web/srv/Input.class.php index 847fe631..19e44a5c 100755 --- a/src/main/php/xp/web/srv/Input.class.php +++ b/src/main/php/xp/web/srv/Input.class.php @@ -17,7 +17,7 @@ class Input implements Base { public $kind= null; public $buffer= null; private $socket; - private $method, $uri, $version, $status, $message; + private $method, $resource, $version, $status, $message; private $incoming= null; /** @@ -65,7 +65,7 @@ public function consume($limit= 16384) { if (3 === sscanf($this->buffer, "HTTP/%[0-9.] %d %[^\r]\r\n", $this->version, $this->status, $this->message)) { $this->kind|= self::RESPONSE; $this->buffer= substr($this->buffer, strpos($this->buffer, "\r\n") + 2); - } else if (3 === sscanf($this->buffer, "%s %s HTTP/%[0-9.]\r\n", $this->method, $this->uri, $this->version)) { + } else if (3 === sscanf($this->buffer, "%s %s HTTP/%[0-9.]\r\n", $this->method, $this->resource, $this->version)) { $this->kind|= self::REQUEST; $this->buffer= substr($this->buffer, strpos($this->buffer, "\r\n") + 2); } else { @@ -102,7 +102,7 @@ public function version() { return $this->version; } public function method() { return $this->method; } /** @return string */ - public function uri() { return $this->uri; } + public function resource() { return $this->resource; } /** @return int */ public function status() { return $this->status; } diff --git a/src/test/php/web/unittest/io/TestInputTest.class.php b/src/test/php/web/unittest/io/TestInputTest.class.php index 18553805..0ff91a9d 100755 --- a/src/test/php/web/unittest/io/TestInputTest.class.php +++ b/src/test/php/web/unittest/io/TestInputTest.class.php @@ -27,9 +27,9 @@ public function method($name) { Assert::equals($name, (new TestInput($name, '/'))->method()); } - #[Test, Values(['/', '/test'])] - public function uri($path) { - Assert::equals($path, (new TestInput('GET', $path))->uri()); + #[Test, Values(['/', '/test', '/?q=Test'])] + public function resource($path) { + Assert::equals($path, (new TestInput('GET', $path))->resource()); } #[Test] diff --git a/src/test/php/web/unittest/server/InputTest.class.php b/src/test/php/web/unittest/server/InputTest.class.php index 39cf9de3..fcb4f06f 100755 --- a/src/test/php/web/unittest/server/InputTest.class.php +++ b/src/test/php/web/unittest/server/InputTest.class.php @@ -81,7 +81,7 @@ public function request_kind() { Assert::equals(Input::REQUEST, $input->kind); Assert::equals('GET', $input->method()); - Assert::equals('/', $input->uri()); + Assert::equals('/', $input->resource()); Assert::equals('1.1', $input->version()); } @@ -148,8 +148,13 @@ public function method() { } #[Test] - public function uri() { - Assert::equals('/', $this->consume($this->socket("GET / HTTP/1.1\r\n\r\n"))->uri()); + public function resource() { + Assert::equals('/', $this->consume($this->socket("GET / HTTP/1.1\r\n\r\n"))->resource()); + } + + #[Test] + public function resource_with_query() { + Assert::equals('/?q=Test', $this->consume($this->socket("GET /?q=Test HTTP/1.1\r\n\r\n"))->resource()); } #[Test] diff --git a/src/test/php/web/unittest/server/SAPITest.class.php b/src/test/php/web/unittest/server/SAPITest.class.php index edc067dd..43551567 100755 --- a/src/test/php/web/unittest/server/SAPITest.class.php +++ b/src/test/php/web/unittest/server/SAPITest.class.php @@ -91,9 +91,9 @@ public function method($value) { } #[Test] - public function uri() { + public function resource() { $_SERVER['REQUEST_URI']= '/favicon.ico'; - Assert::equals('/favicon.ico', (new SAPI())->uri()); + Assert::equals('/favicon.ico', (new SAPI())->resource()); } #[Test] From d06d38432bb28052b5cbaf453a2b9005993201a5 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 13:40:48 +0100 Subject: [PATCH 50/79] Ensure socket to backend is closed --- .../php/xp/web/srv/ForwardRequests.class.php | 61 ++++++++++--------- .../server/ForwardRequestsTest.class.php | 51 ++++++++++++++++ 2 files changed, 83 insertions(+), 29 deletions(-) diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 5c7ba388..0ddb1e75 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -57,40 +57,43 @@ public function handleData($socket) { if (Input::REQUEST === $request->kind) { $this->backend->connect(); - $message= "{$request->method()} {$request->resource()} HTTP/{$request->version()}\r\n"; - $headers= []; - foreach ($request->headers() as $name => $value) { - isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; - $headers[$name]= $value; - } - // \util\cmd\Console::writeLine('>>> ', $message); - $this->backend->write($message."\r\n"); - foreach ($this->transmit($request->incoming(), $this->backend) as $step) { - // yield 'read' => $socket; - } - - $response= new Input($this->backend); - foreach ($response->consume() as $_) { } + try { + $message= "{$request->method()} {$request->resource()} HTTP/{$request->version()}\r\n"; + $headers= []; + foreach ($request->headers() as $name => $value) { + isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; + $headers[$name]= $value; + } + // \util\cmd\Console::writeLine('>>> ', $message); + $this->backend->write($message."\r\n"); + foreach ($this->transmit($request->incoming(), $this->backend) as $step) { + // yield 'read' => $socket; + } - // Switch protocols - if (101 === $response->status()) { - $result= ['websocket', ['path' => $request->resource(), 'headers' => $headers]]; - } else { - $result= null; - } + $response= new Input($this->backend); + foreach ($response->consume() as $_) { } - // yield 'write' => $socket; - $message= "HTTP/{$response->version()} {$response->status()} {$response->message()}\r\n"; - foreach ($response->headers() as $name => $value) { - isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; - } - // \util\cmd\Console::writeLine('<<< ', $message); - $socket->write($message."\r\n"); + // Switch protocols + if (101 === $response->status()) { + $result= ['websocket', ['path' => $request->resource(), 'headers' => $headers]]; + } else { + $result= null; + } - foreach ($this->transmit($response->incoming(), $socket) as $step) { // yield 'write' => $socket; + $message= "HTTP/{$response->version()} {$response->status()} {$response->message()}\r\n"; + foreach ($response->headers() as $name => $value) { + isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; + } + // \util\cmd\Console::writeLine('<<< ', $message); + $socket->write($message."\r\n"); + + foreach ($this->transmit($response->incoming(), $socket) as $step) { + // yield 'write' => $socket; + } + } finally { + $this->backend->close(); } - $this->backend->close(); return $result; } else if (Input::CLOSE === $request->kind) { diff --git a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php index bec4963a..e5a2819b 100755 --- a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -1,5 +1,6 @@ out)); Assert::equals($response, implode('', $client->out)); } + + #[Test] + public function backend_socket_closed() { + $request= $this->message( + 'POST / HTTP/1.0', + 'Transfer-Encoding: chunked', + '', + "4\r\nTest\r\n0\r\n\r\n", + ); + $response= $this->message( + 'HTTP/1.0 201 Created', + 'Location: /test/1', + '', + '' + ); + $client= new Channel([$request]); + $backend= new Channel([$response]); + $this->forward($client, $backend); + + Assert::false($backend->isConnected()); + } + + #[Test] + public function backend_socket_closed_on_errors() { + $request= $this->message( + 'POST / HTTP/1.0', + 'Transfer-Encoding: chunked', + '', + "4\r\nTest\r\n0\r\n\r\n", + ); + $response= $this->message( + 'HTTP/1.0 201 Created', + 'Location: /test/1', + '', + '' + ); + $client= new class([$request]) extends Channel { + public function write($chunk) { + throw new IOException('Test'); + } + }; + $backend= new Channel([$response]); + try { + $this->forward($client, $backend); + } catch (IOException $expected) { + // ... + } + + Assert::false($backend->isConnected()); + } } \ No newline at end of file From bb27996d9f9dac6cb1ca0ef8523f7a4e4e7ebc77 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 21:27:45 +0100 Subject: [PATCH 51/79] Move HttpProtocolTest into dedicated package --- src/main/php/xp/web/srv/HttpProtocol.class.php | 2 +- .../php/web/unittest/{ => server}/HttpProtocolTest.class.php | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) rename src/test/php/web/unittest/{ => server}/HttpProtocolTest.class.php (98%) diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index adad31ed..12adb95d 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -7,7 +7,7 @@ /** * HTTP protocol implementation * - * @test xp://web.unittest.HttpProtocolTest + * @test web.unittest.server.HttpProtocolTest */ class HttpProtocol extends Switchable { private $application, $logging; diff --git a/src/test/php/web/unittest/HttpProtocolTest.class.php b/src/test/php/web/unittest/server/HttpProtocolTest.class.php similarity index 98% rename from src/test/php/web/unittest/HttpProtocolTest.class.php rename to src/test/php/web/unittest/server/HttpProtocolTest.class.php index 93c35419..99e81bb3 100755 --- a/src/test/php/web/unittest/HttpProtocolTest.class.php +++ b/src/test/php/web/unittest/server/HttpProtocolTest.class.php @@ -1,8 +1,9 @@ - Date: Sun, 19 Jan 2025 22:02:05 +0100 Subject: [PATCH 52/79] Add tests for WebsocketProtocol --- .../php/xp/web/srv/HttpProtocol.class.php | 6 +- .../xp/web/srv/WebsocketProtocol.class.php | 1 + src/test/php/web/unittest/Channel.class.php | 4 ++ .../server/WebsocketProtocolTest.class.php | 67 +++++++++++++++++++ 4 files changed, 73 insertions(+), 5 deletions(-) create mode 100755 src/test/php/web/unittest/server/WebsocketProtocolTest.class.php diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index 12adb95d..ce0be6f7 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -4,11 +4,7 @@ use lang\ClassLoader; use web\{Error, InternalServerError, Request, Response, Headers, Status}; -/** - * HTTP protocol implementation - * - * @test web.unittest.server.HttpProtocolTest - */ +/** @test web.unittest.server.HttpProtocolTest */ class HttpProtocol extends Switchable { private $application, $logging; public $server= null; diff --git a/src/main/php/xp/web/srv/WebsocketProtocol.class.php b/src/main/php/xp/web/srv/WebsocketProtocol.class.php index 305e8f03..22dd9b09 100755 --- a/src/main/php/xp/web/srv/WebsocketProtocol.class.php +++ b/src/main/php/xp/web/srv/WebsocketProtocol.class.php @@ -6,6 +6,7 @@ use web\Logging; use websocket\protocol\{Opcodes, Connection}; +/** @test web.unittest.server.WebsocketProtocolTest */ class WebsocketProtocol extends Switchable { private $listener, $logging; private $connections= []; diff --git a/src/test/php/web/unittest/Channel.class.php b/src/test/php/web/unittest/Channel.class.php index 3c95c0c4..0e1aad7c 100755 --- a/src/test/php/web/unittest/Channel.class.php +++ b/src/test/php/web/unittest/Channel.class.php @@ -14,6 +14,10 @@ public function isConnected() { return !$this->closed; } public function remoteEndpoint() { return new SocketEndpoint('127.0.0.1', 6666); } + public function setTimeout($timeout) { } + + public function useNoDelay() { } + public function canRead($timeout= 0.0) { return !empty($this->in); } public function read($maxLen= 4096) { return array_shift($this->in); } diff --git a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php new file mode 100755 index 00000000..3fec4013 --- /dev/null +++ b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php @@ -0,0 +1,67 @@ +handleSwitch($socket, ['path' => '/ws', 'headers' => []]); + try { + foreach ($protocol->handleData($socket) as $_) { } + } finally { + $protocol->handleDisconnect($socket); + } + + return $socket->out; + } + + #[Test] + public function can_create() { + new WebsocketProtocol(new class() extends Listener { + public function message($conn, $message) { } + }); + } + + #[Test] + public function receive_text_message() { + $received= []; + $this->handle(["\x81\x04", "Test"], function($conn, $message) use(&$received) { + $received[]= $message; + }); + + Assert::equals(['Test'], $received); + } + + #[Test] + public function receive_binary_message() { + $received= []; + $this->handle(["\x82\x02", "\x47\x11"], function($conn, $message) use(&$received) { + $received[]= $message; + }); + + Assert::equals([new Bytes("\x47\x11")], $received); + } + + #[Test] + public function answer_text_message() { + $out= $this->handle(["\x81\x04", "Test"], function($conn, $message) use(&$received) { + $conn->send('Re: '.$message); + }); + + Assert::equals(["\x81\x08Re: Test"], $out); + } +} \ No newline at end of file From 7e1d2fa2aec925925a61293c63a372884be00422 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 19 Jan 2025 22:30:44 +0100 Subject: [PATCH 53/79] Test ping and close handling --- .../server/WebsocketProtocolTest.class.php | 43 +++++++++++++++++-- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php index 3fec4013..264db46a 100755 --- a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php +++ b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php @@ -57,11 +57,48 @@ public function receive_binary_message() { } #[Test] - public function answer_text_message() { - $out= $this->handle(["\x81\x04", "Test"], function($conn, $message) use(&$received) { + public function send_messages() { + $out= $this->handle(["\x81\x04", "Test"], function($conn, $message) { $conn->send('Re: '.$message); + $conn->send(new Bytes("\x47\x11")); }); - Assert::equals(["\x81\x08Re: Test"], $out); + Assert::equals(["\x81\x08Re: Test", "\x82\x02\x47\x11"], $out); + } + + #[Test] + public function answers_ping_with_pong_automatically() { + $out= $this->handle(["\x89\x04", "Test"], function($conn, $message) { + // NOOP + }); + + Assert::equals(["\x8a\x04Test"], $out); + } + + #[Test] + public function default_close() { + $out= $this->handle(["\x88\x00"], function($conn, $message) { + // NOOP + }); + + Assert::equals(["\x88\x02\x03\xe8"], $out); + } + + #[Test] + public function answer_with_client_code_and_reason() { + $out= $this->handle(["\x88\x06", "\x03\xe8Test"], function($conn, $message) { + // NOOP + }); + + Assert::equals(["\x88\x06\x03\xe8Test"], $out); + } + + #[Test] + public function protocol_error() { + $out= $this->handle(["\x88\x02", "\x03\xf7"], function($conn, $message) { + // NOOP + }); + + Assert::equals(["\x88\x02\x03\xea"], $out); } } \ No newline at end of file From 29e4d7dd1a07e85d154627cd1b46cc1a162d895c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Mon, 20 Jan 2025 19:11:20 +0100 Subject: [PATCH 54/79] Simplify test code by extracting common code to instance variable --- .../server/WebsocketProtocolTest.class.php | 30 ++++++++----------- 1 file changed, 13 insertions(+), 17 deletions(-) diff --git a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php index 264db46a..1228192f 100755 --- a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php +++ b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php @@ -1,12 +1,13 @@ out; } + #[Before] + public function noop() { + $this->noop= function($conn, $message) { + // NOOP + }; + } + #[Test] public function can_create() { new WebsocketProtocol(new class() extends Listener { @@ -68,37 +76,25 @@ public function send_messages() { #[Test] public function answers_ping_with_pong_automatically() { - $out= $this->handle(["\x89\x04", "Test"], function($conn, $message) { - // NOOP - }); - + $out= $this->handle(["\x89\x04", "Test"], $this->noop); Assert::equals(["\x8a\x04Test"], $out); } #[Test] public function default_close() { - $out= $this->handle(["\x88\x00"], function($conn, $message) { - // NOOP - }); - + $out= $this->handle(["\x88\x00"], $this->noop); Assert::equals(["\x88\x02\x03\xe8"], $out); } #[Test] public function answer_with_client_code_and_reason() { - $out= $this->handle(["\x88\x06", "\x03\xe8Test"], function($conn, $message) { - // NOOP - }); - + $out= $this->handle(["\x88\x06", "\x03\xe8Test"], $this->noop); Assert::equals(["\x88\x06\x03\xe8Test"], $out); } #[Test] public function protocol_error() { - $out= $this->handle(["\x88\x02", "\x03\xf7"], function($conn, $message) { - // NOOP - }); - + $out= $this->handle(["\x88\x02", "\x03\xf7"], $this->noop); Assert::equals(["\x88\x02\x03\xea"], $out); } } \ No newline at end of file From 11428a4c4df03b6635ca4ac127d3740fd09565dc Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 25 Jan 2025 11:40:15 +0100 Subject: [PATCH 55/79] Test logging messages --- .../server/WebsocketProtocolTest.class.php | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php index 1228192f..923db281 100755 --- a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php +++ b/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php @@ -1,7 +1,8 @@ handleSwitch($socket, ['path' => '/ws', 'headers' => []]); try { foreach ($protocol->handleData($socket) as $_) { } @@ -97,4 +99,13 @@ public function protocol_error() { $out= $this->handle(["\x88\x02", "\x03\xf7"], $this->noop); Assert::equals(["\x88\x02\x03\xea"], $out); } + + #[Test, Values([[["\x81\x04", "Test"], 'TEXT /ws'], [["\x82\x02", "\x47\x11"], 'BINARY /ws']])] + public function logs_messages($input, $expected) { + $logged= []; + $this->handle($input, $this->noop, function($status, $method, $uri, $hints) use(&$logged) { + $logged[]= $method.' '.$uri.($hints ? ' '.Objects::stringOf($hints) : ''); + }); + Assert::equals([$expected], $logged); + } } \ No newline at end of file From 5490ca68533b4db691476b5587e97c5e1b162861 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 26 Jan 2025 10:15:14 +0100 Subject: [PATCH 56/79] Remove compatibility with older xp-framework/networking libraries See https://github.com/xp-forge/web/pull/121/files#r1929700241 --- src/main/php/xp/web/srv/Protocol.class.php | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/src/main/php/xp/web/srv/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php index 2907c24e..e873907c 100755 --- a/src/main/php/xp/web/srv/Protocol.class.php +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -9,19 +9,7 @@ class Protocol implements ServerProtocol { /** Creates a new instance of this multiplex protocol */ public static function multiplex(): self { - - // Compatibility with older xp-framework/networking libraries, see issue #79 - // Unwind generators returned from handleData() to guarantee their complete - // execution. - if (class_exists(AsyncServer::class, true)) { - return new self(); - } else { - return new class() extends Protocol { - public function handleData($socket) { - foreach (parent::handleData($socket) as $_) { } - } - }; - } + return new self(); } /** Serves a given protocol */ From d0baa9535c3c3fbcf7c99a95947fdadf4376f423 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 26 Jan 2025 10:28:29 +0100 Subject: [PATCH 57/79] Consistently spell `WebSocket`, step 1 --- .../web/srv/{WebsocketProtocol.class.php => WSProtocol.class.php} | 0 .../{WebsocketProtocolTest.class.php => WSProtocolTest.class.php} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/php/xp/web/srv/{WebsocketProtocol.class.php => WSProtocol.class.php} (100%) rename src/test/php/web/unittest/server/{WebsocketProtocolTest.class.php => WSProtocolTest.class.php} (100%) diff --git a/src/main/php/xp/web/srv/WebsocketProtocol.class.php b/src/main/php/xp/web/srv/WSProtocol.class.php similarity index 100% rename from src/main/php/xp/web/srv/WebsocketProtocol.class.php rename to src/main/php/xp/web/srv/WSProtocol.class.php diff --git a/src/test/php/web/unittest/server/WebsocketProtocolTest.class.php b/src/test/php/web/unittest/server/WSProtocolTest.class.php similarity index 100% rename from src/test/php/web/unittest/server/WebsocketProtocolTest.class.php rename to src/test/php/web/unittest/server/WSProtocolTest.class.php From 8ae6e95de8e5743fe6aadf2fa2e9d96eef552792 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 26 Jan 2025 10:29:02 +0100 Subject: [PATCH 58/79] Consistently spell `WebSocket`, step 2 --- .../web/srv/{WSProtocol.class.php => WebSocketProtocol.class.php} | 0 .../{WSProtocolTest.class.php => WebSocketProtocolTest.class.php} | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename src/main/php/xp/web/srv/{WSProtocol.class.php => WebSocketProtocol.class.php} (100%) rename src/test/php/web/unittest/server/{WSProtocolTest.class.php => WebSocketProtocolTest.class.php} (100%) diff --git a/src/main/php/xp/web/srv/WSProtocol.class.php b/src/main/php/xp/web/srv/WebSocketProtocol.class.php similarity index 100% rename from src/main/php/xp/web/srv/WSProtocol.class.php rename to src/main/php/xp/web/srv/WebSocketProtocol.class.php diff --git a/src/test/php/web/unittest/server/WSProtocolTest.class.php b/src/test/php/web/unittest/server/WebSocketProtocolTest.class.php similarity index 100% rename from src/test/php/web/unittest/server/WSProtocolTest.class.php rename to src/test/php/web/unittest/server/WebSocketProtocolTest.class.php From f195e9c163b1014c8855bc6c967f40beef7f4258 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 26 Jan 2025 10:29:44 +0100 Subject: [PATCH 59/79] Consistently spell `WebSocket`, step 3 --- src/it/php/web/unittest/TestingServer.class.php | 4 ++-- src/main/php/xp/web/srv/Develop.class.php | 2 +- src/main/php/xp/web/srv/Standalone.class.php | 2 +- .../web/unittest/server/WebSocketProtocolTest.class.php | 8 ++++---- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/it/php/web/unittest/TestingServer.class.php b/src/it/php/web/unittest/TestingServer.class.php index da4cbf3c..01952d22 100755 --- a/src/it/php/web/unittest/TestingServer.class.php +++ b/src/it/php/web/unittest/TestingServer.class.php @@ -5,7 +5,7 @@ use peer\server\AsyncServer; use util\cmd\Console; use web\{Environment, Logging}; -use xp\web\srv\{Protocol, HttpProtocol, WebsocketProtocol}; +use xp\web\srv\{Protocol, HttpProtocol, WebSocketProtocol}; /** * Socket server used by integration tests. @@ -29,7 +29,7 @@ public static function main(array $args) { try { $s->listen($socket, Protocol::multiplex() ->serving('http', new HttpProtocol($application, $log)) - ->serving('websocket', new WebsocketProtocol(null, $log)) + ->serving('websocket', new WebSocketProtocol(null, $log)) ); $s->init(); Console::writeLinef('+ Service %s:%d', $socket->host, $socket->port); diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 72f2e4d8..36429e71 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -100,7 +100,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() ->serving('http', new ForwardRequests($backend)) - ->serving('websocket', new WebsocketProtocol(new ForwardMessages($backend))) + ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backend))) ); $impl->init(); $impl->service(); diff --git a/src/main/php/xp/web/srv/Standalone.class.php b/src/main/php/xp/web/srv/Standalone.class.php index 6b0df557..8d5b5bab 100755 --- a/src/main/php/xp/web/srv/Standalone.class.php +++ b/src/main/php/xp/web/srv/Standalone.class.php @@ -62,7 +62,7 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $socket= new ServerSocket($this->host, $this->port); $this->impl->listen($socket, Protocol::multiplex() ->serving('http', new HttpProtocol($application, $environment->logging())) - ->serving('websocket', new WebsocketProtocol(null, $environment->logging())) + ->serving('websocket', new WebSocketProtocol(null, $environment->logging())) ); $this->impl->init(); diff --git a/src/test/php/web/unittest/server/WebSocketProtocolTest.class.php b/src/test/php/web/unittest/server/WebSocketProtocolTest.class.php index 923db281..21fe8bb7 100755 --- a/src/test/php/web/unittest/server/WebSocketProtocolTest.class.php +++ b/src/test/php/web/unittest/server/WebSocketProtocolTest.class.php @@ -5,9 +5,9 @@ use web\Logging; use web\unittest\Channel; use websocket\Listener; -use xp\web\srv\WebsocketProtocol; +use xp\web\srv\WebSocketProtocol; -class WebsocketProtocolTest { +class WebSocketProtocolTest { private $noop; /** @@ -21,7 +21,7 @@ class WebsocketProtocolTest { private function handle($chunks, $listener, $logging= null) { $socket= new Channel($chunks); - $protocol= new WebsocketProtocol(newinstance(Listener::class, [], $listener), Logging::of($logging)); + $protocol= new WebSocketProtocol(newinstance(Listener::class, [], $listener), Logging::of($logging)); $protocol->handleSwitch($socket, ['path' => '/ws', 'headers' => []]); try { foreach ($protocol->handleData($socket) as $_) { } @@ -41,7 +41,7 @@ public function noop() { #[Test] public function can_create() { - new WebsocketProtocol(new class() extends Listener { + new WebSocketProtocol(new class() extends Listener { public function message($conn, $message) { } }); } From c5414d9b51f40621b124bcd8d2a73f114a101492 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 10:32:14 +0100 Subject: [PATCH 60/79] Extract worker logic into dedicated classes --- src/main/php/xp/web/srv/Develop.class.php | 80 +++-------------------- src/main/php/xp/web/srv/Worker.class.php | 56 ++++++++++++++++ src/main/php/xp/web/srv/Workers.class.php | 65 ++++++++++++++++++ 3 files changed, 130 insertions(+), 71 deletions(-) create mode 100755 src/main/php/xp/web/srv/Worker.class.php create mode 100755 src/main/php/xp/web/srv/Workers.class.php diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 36429e71..59c1eeff 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -1,10 +1,8 @@ application($args); $application->initialize(); - // Start the development webserver in the background // PHP doesn't start with a nonexistant document root if (!$docroot->exists()) { $docroot= getcwd(); } - - // Inherit all currently loaded paths acceptable to bootstrapping - $include= '.'.PATH_SEPARATOR.PATH_SEPARATOR.'.'; - foreach (ClassLoader::getLoaders() as $delegate) { - if ($delegate instanceof FileSystemClassLoader || $delegate instanceof ArchiveClassLoader) { - $include.= PATH_SEPARATOR.$delegate->path; - } - } - - // Start `php -S`, the development webserver - $runtime= Runtime::getInstance(); - $os= CommandLine::forName(PHP_OS); - $arguments= ['-S', '127.0.0.1:0', '-t', $docroot]; - $cmd= $os->compose($runtime->getExecutable()->getFileName(), array_merge( - $arguments, - $runtime->startupOptions() - ->withSetting('user_dir', $docroot) - ->withSetting('include_path', $include) - ->withSetting('output_buffering', 0) - ->asArguments() - , - [$runtime->bootstrapScript('web')] - )); + $workers= new Workers($docroot, ClassLoader::getLoaders()); // Export environment putenv('DOCUMENT_ROOT='.$docroot); @@ -65,25 +40,11 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo putenv('WEB_ARGS='.implode('|', $args)); putenv('WEB_LOG='.$logging); - Console::writeLine("\e[33m@", nameof($this), "(HTTP @ `php ", implode(' ', $arguments), "`)\e[0m"); + Console::writeLine("\e[33m@", nameof($this), "(HTTP @ `php -S [...] -t {$docroot}`)\e[0m"); Console::writeLine("\e[1mServing {$profile}:", $application, $config, "\e[0m > ", $environment->logging()->target()); Console::writeLine("\e[36m", str_repeat('═', 72), "\e[0m"); - // Replace launching shell with PHP - if ('WINDOWS' !== $os->name()) $cmd= 'exec '.$cmd; - if (!($proc= proc_open($cmd, [STDIN, STDOUT, ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]))) { - throw new IOException('Cannot execute `'.$runtime->getExecutable()->getFileName().'`'); - } - - // Parse `[...] PHP 8.3.15 Development Server (http://127.0.0.1:60922) started` - $line= fgets($pipes[2], 1024); - if (!preg_match('/\([a-z]+:\/\/([0-9.]+):([0-9]+)\)/', $line, $matches)) { - proc_terminate($proc, 2); - proc_close($proc); - throw new IOException('Cannot determine bound port: `'.trim($line).'`'); - } - $backend= new Socket($matches[1], $matches[2]); - + $backend= $workers->launch(); Console::writeLinef( "\e[33;1m>\e[0m Server started: \e[35;4mhttp://%s:%d/\e[0m in %.3f seconds\n". " %s - PID %d -> %d @ :%d; press Enter to exit\n", @@ -92,40 +53,17 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], date('r'), getmypid(), - proc_get_status($proc)['pid'], - $matches[2], + $backend->pid(), + $backend->socket->port, ); // Start the multiplex protocol in the foreground and forward requests $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() - ->serving('http', new ForwardRequests($backend)) - ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backend))) + ->serving('http', new ForwardRequests($backend->socket)) + ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backend->socket))) ); $impl->init(); $impl->service(); - - // Inside `xp -supervise`, connect to signalling socket - if ($port= getenv('XP_SIGNAL')) { - $s= new Socket('127.0.0.1', $port); - $s->connect(); - $s->canRead(null) && $s->read(); - $s->close(); - } else { - Console::read(); - Console::write('> Shut down '); - } - - // Wait for shutdown - proc_terminate($proc, 2); - do { - Console::write('.'); - $status= proc_get_status($proc); - usleep(100 * 1000); - } while ($status['running']); - - proc_close($proc); - Console::writeLine(); - Console::writeLine("\e[33;1m>\e[0m Server stopped. (", date('r'), ')'); } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Worker.class.php b/src/main/php/xp/web/srv/Worker.class.php new file mode 100755 index 00000000..432bbf40 --- /dev/null +++ b/src/main/php/xp/web/srv/Worker.class.php @@ -0,0 +1,56 @@ +handle= $handle; + $this->socket= $socket; + } + + /** @return ?int */ + public function pid() { + return $this->handle ? proc_get_status($this->handle)['pid'] : null; + } + + /** @return bool */ + public function running() { + return $this->handle ? proc_get_status($this->handle)['running'] : false; + } + + /** + * Shuts down this worker + * + * @throws lang.IllegalStateException + * @return void + */ + public function shutdown() { + if (!$this->handle) throw new IllegalStateException('Worker not running'); + + proc_terminate($this->handle, 2); + } + + /** @return void */ + public function close() { + if (!$this->handle) return; + + proc_close($this->handle); + $this->handle= null; + } + + /** @return void */ + public function __destruct() { + $this->close(); + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php new file mode 100755 index 00000000..a50f00e3 --- /dev/null +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -0,0 +1,65 @@ +path; + } + } + + // Replace launching shell with PHP on Un*x + $os= CommandLine::forName(PHP_OS); + $this->commandLine= $os->compose($runtime->getExecutable()->getFileName(), array_merge( + ['-S', '127.0.0.1:0', '-t', $docroot], + $runtime->startupOptions() + ->withSetting('user_dir', $docroot) + ->withSetting('include_path', $include) + ->withSetting('output_buffering', 0) + ->asArguments() + , + [$runtime->bootstrapScript('web')] + )); + if ('WINDOWS' !== $os->name()) $this->commandLine= 'exec '.$this->commandLine; + } + + /** + * Launches a worker and returns it. + * + * @throws io.IOException + * @return xp.web.srv.Worker + */ + public function launch() { + if (!($proc= proc_open($this->commandLine, [STDIN, STDOUT, ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]))) { + throw new IOException('Cannot execute `'.$this->commandLine.'`'); + } + + // Parse `[...] PHP 8.3.15 Development Server (http://127.0.0.1:60922) started` + $line= fgets($pipes[2], 1024); + if (!preg_match('/\([a-z]+:\/\/([0-9.]+):([0-9]+)\)/', $line, $matches)) { + proc_terminate($proc, 2); + proc_close($proc); + throw new IOException('Cannot determine bound port: `'.trim($line).'`'); + } + + return new Worker($proc, new Socket($matches[1], (int)$matches[2])); + } +} \ No newline at end of file From df80df8c4340e4e71b09da22db75165c92b4b360 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 10:35:14 +0100 Subject: [PATCH 61/79] Call shutdown() --- src/main/php/xp/web/srv/Develop.class.php | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 59c1eeff..f866034b 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -65,5 +65,8 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo ); $impl->init(); $impl->service(); + $impl->shutdown(); + + $backend->shutdown(); } } \ No newline at end of file From 3de6518ef67efb9374c0b70b63675423e27cb09c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 11:16:26 +0100 Subject: [PATCH 62/79] Make ForwardRequests / ForwardMessages accept Worker instances --- src/main/php/xp/web/srv/Develop.class.php | 4 ++-- src/main/php/xp/web/srv/ForwardMessages.class.php | 4 ++-- src/main/php/xp/web/srv/ForwardRequests.class.php | 4 ++-- .../unittest/server/ForwardMessagesTest.class.php | 14 +++++++------- .../unittest/server/ForwardRequestsTest.class.php | 6 +++--- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index f866034b..a98ca3b0 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -60,8 +60,8 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo // Start the multiplex protocol in the foreground and forward requests $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() - ->serving('http', new ForwardRequests($backend->socket)) - ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backend->socket))) + ->serving('http', new ForwardRequests($backend)) + ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backend))) ); $impl->init(); $impl->service(); diff --git a/src/main/php/xp/web/srv/ForwardMessages.class.php b/src/main/php/xp/web/srv/ForwardMessages.class.php index eeb11e8e..6233b46e 100755 --- a/src/main/php/xp/web/srv/ForwardMessages.class.php +++ b/src/main/php/xp/web/srv/ForwardMessages.class.php @@ -18,8 +18,8 @@ class ForwardMessages extends Listener { private $backend; /** Creates a new instance */ - public function __construct(Socket $backend) { - $this->backend= $backend; + public function __construct(Worker $worker) { + $this->backend= $worker->socket; } /** diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index 0ddb1e75..fbfd5511 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -12,8 +12,8 @@ class ForwardRequests extends Switchable { private $backend; /** Creates a new instance */ - public function __construct(Socket $backend) { - $this->backend= $backend; + public function __construct(Worker $worker) { + $this->backend= $worker->socket; } /** diff --git a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php index 07eef8e1..133c9757 100755 --- a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php +++ b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php @@ -4,7 +4,7 @@ use util\Bytes; use web\unittest\Channel; use websocket\protocol\Connection; -use xp\web\srv\ForwardMessages; +use xp\web\srv\{ForwardMessages, Worker}; class ForwardMessagesTest { const WSID= 6100; @@ -16,7 +16,7 @@ private function message(...$lines): string { #[Test] public function can_create() { - new ForwardMessages(new Channel([])); + new ForwardMessages(new Worker(null, new Channel([]))); } #[Test, Values(["d\r\ndata: Tested\n\r\n0\r\n\r\n", "19\r\nevent: text\ndata: Tested\n\r\n0\r\n\r\n"])] @@ -40,7 +40,7 @@ public function text($payload) { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new ForwardMessages($backend); + $fixture= new ForwardMessages(new Worker(null, $backend)); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); @@ -69,7 +69,7 @@ public function binary() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new ForwardMessages($backend); + $fixture= new ForwardMessages(new Worker(null, $backend)); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); @@ -98,7 +98,7 @@ public function close() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new ForwardMessages($backend); + $fixture= new ForwardMessages(new Worker(null, $backend)); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); @@ -127,7 +127,7 @@ public function unexpected_type() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new ForwardMessages($backend); + $fixture= new ForwardMessages(new Worker(null, $backend)); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); @@ -156,7 +156,7 @@ public function backend_error() { $backend= new Channel([$response]); $ws= new Channel([]); - $fixture= new ForwardMessages($backend); + $fixture= new ForwardMessages(new Worker(null, $backend)); $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); Assert::equals($request, implode('', $backend->out)); diff --git a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php index e5a2819b..42c786c1 100755 --- a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -3,7 +3,7 @@ use io\IOException; use test\{Assert, Test}; use web\unittest\Channel; -use xp\web\srv\ForwardRequests; +use xp\web\srv\{ForwardRequests, Worker}; class ForwardRequestsTest { @@ -14,12 +14,12 @@ private function message(...$lines): string { /** @return void */ private function forward(Channel $client, Channel $backend) { - foreach ((new ForwardRequests($backend))->handleData($client) ?? [] as $_) { } + foreach ((new ForwardRequests(new Worker(null, $backend)))->handleData($client) ?? [] as $_) { } } #[Test] public function can_create() { - new ForwardRequests(new Channel([])); + new ForwardRequests(new Worker(null, new Channel([]))); } #[Test] From 209df508901ec4c3276597b9e767a948e45bfb7f Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 11:34:04 +0100 Subject: [PATCH 63/79] Add tests for workers --- src/main/php/xp/web/srv/Workers.class.php | 6 ++- .../web/unittest/server/WorkersTest.class.php | 44 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100755 src/test/php/web/unittest/server/WorkersTest.class.php diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index a50f00e3..cd7c4a89 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -5,7 +5,11 @@ use lang\{Runtime, RuntimeOptions, CommandLine, FileSystemClassLoader}; use peer\Socket; -/** Start PHP development webservers as background workers */ +/** + * Start PHP development webservers as background workers + * + * @test web.unittest.server.WorkersTest + */ class Workers { private $commandLine; diff --git a/src/test/php/web/unittest/server/WorkersTest.class.php b/src/test/php/web/unittest/server/WorkersTest.class.php new file mode 100755 index 00000000..b2333a4e --- /dev/null +++ b/src/test/php/web/unittest/server/WorkersTest.class.php @@ -0,0 +1,44 @@ +launch(); + try { + $assertions($worker); + } finally { + $worker->shutdown(); + } + } + + #[Test] + public function running() { + $this->test(function($worker) { + Assert::true($worker->running()); + }); + } + + #[Test] + public function pid() { + $this->test(function($worker) { + Assert::notEquals(null, $worker->pid()); + }); + } + + #[Test] + public function execute_http_requests() { + $this->test(function($worker) { + $worker->socket->connect(); + try { + $worker->socket->write("GET / HTTP/1.0\r\n\r\n"); + Assert::equals('HTTP/1.0 200 OK', $worker->socket->readLine()); + } finally { + $worker->socket->close(); + } + }); + } +} \ No newline at end of file From a62ac31b34e97993a0b4b6bac4957c4742c3a218 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 11:53:13 +0100 Subject: [PATCH 64/79] Reuse worker instance --- .../web/unittest/server/WorkersTest.class.php | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/src/test/php/web/unittest/server/WorkersTest.class.php b/src/test/php/web/unittest/server/WorkersTest.class.php index b2333a4e..be1e70df 100755 --- a/src/test/php/web/unittest/server/WorkersTest.class.php +++ b/src/test/php/web/unittest/server/WorkersTest.class.php @@ -1,44 +1,39 @@ launch(); - try { - $assertions($worker); - } finally { - $worker->shutdown(); - } + #[Before] + public function worker() { + $this->worker= (new Workers('.', []))->launch(); } #[Test] public function running() { - $this->test(function($worker) { - Assert::true($worker->running()); - }); + Assert::true($this->worker->running()); } #[Test] public function pid() { - $this->test(function($worker) { - Assert::notEquals(null, $worker->pid()); - }); + Assert::notEquals(null, $this->worker->pid()); } #[Test] public function execute_http_requests() { - $this->test(function($worker) { - $worker->socket->connect(); - try { - $worker->socket->write("GET / HTTP/1.0\r\n\r\n"); - Assert::equals('HTTP/1.0 200 OK', $worker->socket->readLine()); - } finally { - $worker->socket->close(); - } - }); + $this->worker->socket->connect(); + try { + $this->worker->socket->write("GET / HTTP/1.0\r\n\r\n"); + Assert::matches('/^HTTP\/1.0 [0-9]{3} .+/', $this->worker->socket->readLine()); + } finally { + $this->worker->socket->close(); + } + } + + #[After] + public function shutdown() { + $this->worker && $this->worker->shutdown(); } } \ No newline at end of file From d9e880e0fd2527177fecea3e2063074f1cf6170e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 12:21:53 +0100 Subject: [PATCH 65/79] Add workaround for PHP 7.4 not supporting ephemeral ports --- src/main/php/xp/web/srv/Workers.class.php | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index cd7c4a89..e1d3d451 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -30,10 +30,20 @@ public function __construct($docroot, $classLoaders) { } } + // PHP 7.4 doesn't support ephemeral ports, see this commit which went into PHP 8.0: + // https://github.com/php/php-src/commit/a61a9fe9a0d63734136f995451a1fd35b0176292 + if (PHP_VERSION_ID <= 80000) { + $s= stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); + $listen= stream_socket_get_name($s, false); + fclose($s); + } else { + $listen= '127.0.0.1:0'; + } + // Replace launching shell with PHP on Un*x $os= CommandLine::forName(PHP_OS); $this->commandLine= $os->compose($runtime->getExecutable()->getFileName(), array_merge( - ['-S', '127.0.0.1:0', '-t', $docroot], + ['-S', $listen, '-t', $docroot], $runtime->startupOptions() ->withSetting('user_dir', $docroot) ->withSetting('include_path', $include) From 3cb37554f40a2e0632a9b31d6277aebcd923d068 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 12:22:39 +0100 Subject: [PATCH 66/79] Pass through `opcache.enable` flag to (hopefully) prevent "JIT is incompatible" warnings --- src/main/php/xp/web/srv/Workers.class.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index e1d3d451..1c1246dd 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -48,6 +48,7 @@ public function __construct($docroot, $classLoaders) { ->withSetting('user_dir', $docroot) ->withSetting('include_path', $include) ->withSetting('output_buffering', 0) + ->withSetting('opcache.enable', ini_get('opcache.enable')) ->asArguments() , [$runtime->bootstrapScript('web')] From c3f326a76f7dfb28fdbc75984f8781548ac6ff19 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 12:28:31 +0100 Subject: [PATCH 67/79] Prevent startup warnings from creating problems with parsing log line --- src/main/php/xp/web/srv/Workers.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index 1c1246dd..c9f957ac 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -48,7 +48,7 @@ public function __construct($docroot, $classLoaders) { ->withSetting('user_dir', $docroot) ->withSetting('include_path', $include) ->withSetting('output_buffering', 0) - ->withSetting('opcache.enable', ini_get('opcache.enable')) + ->withSetting('display_startup_errors', 0) ->asArguments() , [$runtime->bootstrapScript('web')] From b61335ab037e6966de556afc532491543a9671d3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 12:38:12 +0100 Subject: [PATCH 68/79] Parse over all warnings --- src/main/php/xp/web/srv/Workers.class.php | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index c9f957ac..868722c4 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -48,7 +48,6 @@ public function __construct($docroot, $classLoaders) { ->withSetting('user_dir', $docroot) ->withSetting('include_path', $include) ->withSetting('output_buffering', 0) - ->withSetting('display_startup_errors', 0) ->asArguments() , [$runtime->bootstrapScript('web')] @@ -68,11 +67,16 @@ public function launch() { } // Parse `[...] PHP 8.3.15 Development Server (http://127.0.0.1:60922) started` - $line= fgets($pipes[2], 1024); + $lines= []; + do { + $line= fgets($pipes[2], 1024); + $lines[]= $line; + } while ($line && preg_match('/PHP Warning: /', $line)); + if (!preg_match('/\([a-z]+:\/\/([0-9.]+):([0-9]+)\)/', $line, $matches)) { proc_terminate($proc, 2); proc_close($proc); - throw new IOException('Cannot determine bound port: `'.trim($line).'`'); + throw new IOException('Cannot determine bound port: `'.implode('', $lines).'`'); } return new Worker($proc, new Socket($matches[1], (int)$matches[2])); From 5afae7c0e6048ac8139793e049a17f252dc8eedd Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 12:49:07 +0100 Subject: [PATCH 69/79] Ensure launch() always selects a new free port --- src/main/php/xp/web/srv/Workers.class.php | 28 ++++++++++++----------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index 868722c4..8bbbe163 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -30,20 +30,10 @@ public function __construct($docroot, $classLoaders) { } } - // PHP 7.4 doesn't support ephemeral ports, see this commit which went into PHP 8.0: - // https://github.com/php/php-src/commit/a61a9fe9a0d63734136f995451a1fd35b0176292 - if (PHP_VERSION_ID <= 80000) { - $s= stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); - $listen= stream_socket_get_name($s, false); - fclose($s); - } else { - $listen= '127.0.0.1:0'; - } - // Replace launching shell with PHP on Un*x $os= CommandLine::forName(PHP_OS); $this->commandLine= $os->compose($runtime->getExecutable()->getFileName(), array_merge( - ['-S', $listen, '-t', $docroot], + ['-S', '127.0.0.1:0', '-t', $docroot], $runtime->startupOptions() ->withSetting('user_dir', $docroot) ->withSetting('include_path', $include) @@ -62,8 +52,20 @@ public function __construct($docroot, $classLoaders) { * @return xp.web.srv.Worker */ public function launch() { - if (!($proc= proc_open($this->commandLine, [STDIN, STDOUT, ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]))) { - throw new IOException('Cannot execute `'.$this->commandLine.'`'); + + // PHP 7.4 doesn't support ephemeral ports, see this commit which went into PHP 8.0: + // https://github.com/php/php-src/commit/a61a9fe9a0d63734136f995451a1fd35b0176292 + if (PHP_VERSION_ID <= 80000) { + $s= stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); + $listen= stream_socket_get_name($s, false); + fclose($s); + $commandLine= str_replace('127.0.0.1:0', $listen, $this->commandLine); + } else { + $commandLine= $this->commandLine; + } + + if (!($proc= proc_open($commandLine, [STDIN, STDOUT, ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]))) { + throw new IOException('Cannot execute `'.$commandLine.'`'); } // Parse `[...] PHP 8.3.15 Development Server (http://127.0.0.1:60922) started` From 7d22b851d23bbbba912990887e4704b0cb1641fd Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 12:58:55 +0100 Subject: [PATCH 70/79] Fix possible problem when passing `127.0.0.1:0` as command line argument --- src/main/php/xp/web/srv/Workers.class.php | 32 +++++++++++------------ 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/main/php/xp/web/srv/Workers.class.php b/src/main/php/xp/web/srv/Workers.class.php index 8bbbe163..5c7020a6 100755 --- a/src/main/php/xp/web/srv/Workers.class.php +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -11,7 +11,7 @@ * @test web.unittest.server.WorkersTest */ class Workers { - private $commandLine; + private $executable, $arguments; /** * Creates a new worker @@ -30,19 +30,14 @@ public function __construct($docroot, $classLoaders) { } } - // Replace launching shell with PHP on Un*x - $os= CommandLine::forName(PHP_OS); - $this->commandLine= $os->compose($runtime->getExecutable()->getFileName(), array_merge( - ['-S', '127.0.0.1:0', '-t', $docroot], - $runtime->startupOptions() - ->withSetting('user_dir', $docroot) - ->withSetting('include_path', $include) - ->withSetting('output_buffering', 0) - ->asArguments() - , - [$runtime->bootstrapScript('web')] - )); - if ('WINDOWS' !== $os->name()) $this->commandLine= 'exec '.$this->commandLine; + $this->executable= $runtime->getExecutable()->getFileName(); + $this->arguments= $runtime->startupOptions() + ->withSetting('user_dir', $docroot) + ->withSetting('include_path', $include) + ->withSetting('output_buffering', 0) + ->asArguments() + ; + $this->arguments[]= $runtime->bootstrapScript('web'); } /** @@ -59,11 +54,16 @@ public function launch() { $s= stream_socket_server('tcp://127.0.0.1:0', $errno, $errstr, STREAM_SERVER_BIND); $listen= stream_socket_get_name($s, false); fclose($s); - $commandLine= str_replace('127.0.0.1:0', $listen, $this->commandLine); } else { - $commandLine= $this->commandLine; + $listen= '127.0.0.1:0'; } + $os= CommandLine::forName(PHP_OS); + $commandLine= $os->compose($this->executable, ['-S', $listen, ...$this->arguments]); + + // Replace launching shell with PHP on Un*x + if ('WINDOWS' !== $os->name()) $commandLine= 'exec '.$commandLine; + if (!($proc= proc_open($commandLine, [STDIN, STDOUT, ['pipe', 'w']], $pipes, null, null, ['bypass_shell' => true]))) { throw new IOException('Cannot execute `'.$commandLine.'`'); } From 0a1d730fe0b41520f0629c055ae76724025ee8d3 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 2 Feb 2025 17:35:37 +0100 Subject: [PATCH 71/79] Remove SOMAXCONN discovery Released in https://github.com/xp-framework/networking/releases/tag/v10.0.1, we require 10.1+ so this feature is guaranteed to be there --- src/main/php/xp/web/srv/Standalone.class.php | 23 -------------------- 1 file changed, 23 deletions(-) diff --git a/src/main/php/xp/web/srv/Standalone.class.php b/src/main/php/xp/web/srv/Standalone.class.php index 8d5b5bab..69eddf2e 100755 --- a/src/main/php/xp/web/srv/Standalone.class.php +++ b/src/main/php/xp/web/srv/Standalone.class.php @@ -8,29 +8,6 @@ class Standalone extends Server { private $impl; - static function __static() { - if (defined('SOMAXCONN')) return; - - // Discover SOMAXCONN depending on platform, using 128 as fallback - // See https://stackoverflow.com/q/1198564 - if (0 === strncasecmp(PHP_OS, 'Win', 3)) { - $value= 0x7fffffff; - } else if (file_exists('/proc/sys/net/core/somaxconn')) { - $value= (int)file_get_contents('/proc/sys/net/core/somaxconn'); - } else if (file_exists('/etc/sysctl.conf')) { - $value= 128; - foreach (file('/etc/sysctl.conf') as $line) { - if (0 === strncmp($line, 'kern.ipc.somaxconn=', 19)) { - $value= (int)substr($line, 19); - break; - } - } - } else { - $value= 128; - } - define('SOMAXCONN', $value); - } - /** * Creates a new instance * From 26d5ca651302d5199e475feb395f102fdee70b7e Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 1 Mar 2025 10:03:11 +0100 Subject: [PATCH 72/79] Fix "Creation of dynamic property [...] is deprecated" --- src/main/php/xp/web/srv/HttpProtocol.class.php | 1 - src/main/php/xp/web/srv/Protocol.class.php | 1 + 2 files changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/xp/web/srv/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index ce0be6f7..b8d88c81 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -7,7 +7,6 @@ /** @test web.unittest.server.HttpProtocolTest */ class HttpProtocol extends Switchable { private $application, $logging; - public $server= null; private $close= false; /** diff --git a/src/main/php/xp/web/srv/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php index e873907c..7e2ff9ce 100755 --- a/src/main/php/xp/web/srv/Protocol.class.php +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -6,6 +6,7 @@ /** Multiplex protocol */ class Protocol implements ServerProtocol { private $protocols= []; + public $server= null; /** Creates a new instance of this multiplex protocol */ public static function multiplex(): self { From bf74ae414fd0fffc3b318d9a0a6da31ccaea81e1 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 11 May 2025 09:54:05 +0200 Subject: [PATCH 73/79] QA: Canonicalize link to PHP docs --- src/main/php/xp/web/Runner.class.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/php/xp/web/Runner.class.php b/src/main/php/xp/web/Runner.class.php index ca281dc8..a83d5957 100755 --- a/src/main/php/xp/web/Runner.class.php +++ b/src/main/php/xp/web/Runner.class.php @@ -21,7 +21,7 @@ * ```sh * $ xp web -m prefork,50 ... * ``` - * - Use [development webserver](http://php.net/features.commandline.webserver): + * - Use [development webserver](https://www.php.net/features.commandline.webserver): * ```sh * $ xp web -m develop ... * ``` From 92c133499db6ef7541d386acc9f260ef0a208dd7 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 11 May 2025 10:22:14 +0200 Subject: [PATCH 74/79] Add possibility to use multiple workers in development webserver --- src/main/php/xp/web/Runner.class.php | 4 +- src/main/php/xp/web/Servers.class.php | 24 ++++++- src/main/php/xp/web/srv/Develop.class.php | 64 +++++++++++++++---- .../php/xp/web/srv/Distribution.class.php | 21 ++++++ .../php/xp/web/srv/ForwardMessages.class.php | 20 +++--- .../php/xp/web/srv/ForwardRequests.class.php | 32 +++++----- src/main/php/xp/web/srv/Standalone.class.php | 1 - 7 files changed, 122 insertions(+), 44 deletions(-) create mode 100755 src/main/php/xp/web/srv/Distribution.class.php diff --git a/src/main/php/xp/web/Runner.class.php b/src/main/php/xp/web/Runner.class.php index a83d5957..ee8cca42 100755 --- a/src/main/php/xp/web/Runner.class.php +++ b/src/main/php/xp/web/Runner.class.php @@ -19,11 +19,11 @@ * ``` * - On Un*x systems, start multiprocess server with 50 children: * ```sh - * $ xp web -m prefork,50 ... + * $ xp web -m prefork,children=50 ... * ``` * - Use [development webserver](https://www.php.net/features.commandline.webserver): * ```sh - * $ xp web -m develop ... + * $ xp web -m develop[,workers=5] ... * ``` * The address the server listens to can be supplied via *-a {host}[:{port}]*. * The profile can be changed via *-p {profile}* (and can be anything!). One diff --git a/src/main/php/xp/web/Servers.class.php b/src/main/php/xp/web/Servers.class.php index 809e6ff1..0507738b 100755 --- a/src/main/php/xp/web/Servers.class.php +++ b/src/main/php/xp/web/Servers.class.php @@ -18,17 +18,37 @@ public function newInstance($address, $arguments= []) { self::$PREFORK= new class(1, 'PREFORK') extends Servers { static function __static() { } public function newInstance($address, $arguments= []) { - return new Standalone($address, new PreforkingServer(null, null, ...$arguments)); + return new Standalone($address, new PreforkingServer( + null, + null, + $this->select($arguments, 'children') ?? $arguments[0] ?? 10 + )); } }; self::$DEVELOP= new class(2, 'DEVELOP') extends Servers { static function __static() { } public function newInstance($address, $arguments= []) { - return new Develop($address); + return new Develop($address, $this->select($arguments, 'workers') ?? $arguments[0] ?? 1); } }; } + /** + * Selects a named argument supplied as `[name]=[value]`. + * + * @param string[] $arguments + * @param string $name + * @return ?string + */ + protected function select($arguments, $name) { + foreach ($arguments as $i => $argument) { + if (false !== ($p= strpos($argument, '=')) && 0 === strncmp($argument, $name, $p)) { + return substr($argument, $p + 1); + } + } + return null; + } + /** * Creates a new instance. Implemented by enum values. * diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index a98ca3b0..53c6d8fc 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -1,13 +1,25 @@ workers= $workers; + } /** * Serve requests @@ -29,7 +41,6 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo if (!$docroot->exists()) { $docroot= getcwd(); } - $workers= new Workers($docroot, ClassLoader::getLoaders()); // Export environment putenv('DOCUMENT_ROOT='.$docroot); @@ -44,29 +55,56 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo Console::writeLine("\e[1mServing {$profile}:", $application, $config, "\e[0m > ", $environment->logging()->target()); Console::writeLine("\e[36m", str_repeat('═', 72), "\e[0m"); - $backend= $workers->launch(); + $workers= new Workers($docroot, ClassLoader::getLoaders()); + $backends= []; + for ($i= 0; $i < $this->workers; $i++) { + $backends[]= $workers->launch(); + } Console::writeLinef( "\e[33;1m>\e[0m Server started: \e[35;4mhttp://%s:%d/\e[0m in %.3f seconds\n". - " %s - PID %d -> %d @ :%d; press Enter to exit\n", - '0.0.0.0' === $this->host ? '127.0.0.1' : $this->host, + " %s - PID %d -> %d worker(s); press Enter to exit\n", + '0.0.0.0' === $this->host ? 'localhost' : $this->host, $this->port, microtime(true) - $_SERVER['REQUEST_TIME_FLOAT'], date('r'), getmypid(), - $backend->pid(), - $backend->socket->port, + $this->workers, ); // Start the multiplex protocol in the foreground and forward requests $impl= new AsyncServer(); $impl->listen(new ServerSocket($this->host, $this->port), Protocol::multiplex() - ->serving('http', new ForwardRequests($backend)) - ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backend))) + ->serving('http', new ForwardRequests($backends)) + ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backends))) ); - $impl->init(); - $impl->service(); - $impl->shutdown(); - $backend->shutdown(); + // Inside `xp -supervise`, connect to signalling socket. Unfortunately, there + // is no way to signal "no timeout", so set a pretty high timeout of one year, + // then catch and handle it by continuing to check for reads. + if ($port= getenv('XP_SIGNAL')) { + $signal= new Socket('127.0.0.1', $port); + $signal->setTimeout(31536000); + $signal->connect(); + $impl->select($signal, function() use($impl) { + try { + next: yield 'read' => null; + } catch (SocketTimeoutException $e) { + goto next; + } + $impl->shutdown(); + }); + } + + try { + $impl->init(); + $impl->service(); + } finally { + Console::write('['); + foreach ($backends as $backend) { + Console::write('.'); + $backend->shutdown(); + } + Console::writeLine(']'); + } } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/Distribution.class.php b/src/main/php/xp/web/srv/Distribution.class.php new file mode 100755 index 00000000..fe0d10b5 --- /dev/null +++ b/src/main/php/xp/web/srv/Distribution.class.php @@ -0,0 +1,21 @@ +workers= $workers; + } + + /** Returns first available idle worker socket */ + private function select(): ?Socket { + foreach ($this->workers as $worker) { + if (!$worker->socket->isConnected()) return $worker->socket; + } + return null; + } +} \ No newline at end of file diff --git a/src/main/php/xp/web/srv/ForwardMessages.class.php b/src/main/php/xp/web/srv/ForwardMessages.class.php index 6233b46e..3cf63fdd 100755 --- a/src/main/php/xp/web/srv/ForwardMessages.class.php +++ b/src/main/php/xp/web/srv/ForwardMessages.class.php @@ -15,12 +15,7 @@ * @see https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events */ class ForwardMessages extends Listener { - private $backend; - - /** Creates a new instance */ - public function __construct(Worker $worker) { - $this->backend= $worker->socket; - } + use Distribution; /** * Handles incoming message @@ -41,11 +36,16 @@ public function message($conn, $message) { $request.= "{$name}: {$value}\r\n"; } + // Wait briefly before retrying to find an available worker + while (null === ($backend= $this->select())) { + yield 'delay' => 1; + } + try { - $this->backend->connect(); - $this->backend->write($request."\r\n".$message); + $backend->connect(); + $backend->write($request."\r\n".$message); - $response= new Input($this->backend); + $response= new Input($backend); foreach ($response->consume() as $_) { } if (200 !== $response->status()) { throw new IllegalStateException('Unexpected status code from backend://'.$conn->path().': '.$response->status()); @@ -71,7 +71,7 @@ public function message($conn, $message) { $conn->answer(Opcodes::CLOSE, pack('na*', 1011, $e->getMessage())); $conn->close(); } finally { - $this->backend->close(); + $backend->close(); } } } \ No newline at end of file diff --git a/src/main/php/xp/web/srv/ForwardRequests.class.php b/src/main/php/xp/web/srv/ForwardRequests.class.php index fbfd5511..b881a981 100755 --- a/src/main/php/xp/web/srv/ForwardRequests.class.php +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -1,6 +1,5 @@ backend= $worker->socket; - } + use Distribution; /** * Transmits data from an optional stream to a given target socket, @@ -56,7 +50,13 @@ public function handleData($socket) { yield from $request->consume(); if (Input::REQUEST === $request->kind) { - $this->backend->connect(); + + // Wait briefly before retrying to find an available worker + while (null === ($backend= $this->select())) { + yield 'delay' => 1; + } + + $backend->connect(); try { $message= "{$request->method()} {$request->resource()} HTTP/{$request->version()}\r\n"; $headers= []; @@ -65,12 +65,12 @@ public function handleData($socket) { $headers[$name]= $value; } // \util\cmd\Console::writeLine('>>> ', $message); - $this->backend->write($message."\r\n"); - foreach ($this->transmit($request->incoming(), $this->backend) as $step) { - // yield 'read' => $socket; + $backend->write($message."\r\n"); + foreach ($this->transmit($request->incoming(), $backend) as $_) { + yield 'read' => null; } - $response= new Input($this->backend); + $response= new Input($backend); foreach ($response->consume() as $_) { } // Switch protocols @@ -80,7 +80,7 @@ public function handleData($socket) { $result= null; } - // yield 'write' => $socket; + yield 'write' => null; $message= "HTTP/{$response->version()} {$response->status()} {$response->message()}\r\n"; foreach ($response->headers() as $name => $value) { isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; @@ -88,11 +88,11 @@ public function handleData($socket) { // \util\cmd\Console::writeLine('<<< ', $message); $socket->write($message."\r\n"); - foreach ($this->transmit($response->incoming(), $socket) as $step) { - // yield 'write' => $socket; + foreach ($this->transmit($response->incoming(), $socket) as $_) { + yield 'write' => null; } } finally { - $this->backend->close(); + $backend->close(); } return $result; diff --git a/src/main/php/xp/web/srv/Standalone.class.php b/src/main/php/xp/web/srv/Standalone.class.php index 69eddf2e..eb45e68a 100755 --- a/src/main/php/xp/web/srv/Standalone.class.php +++ b/src/main/php/xp/web/srv/Standalone.class.php @@ -58,6 +58,5 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo ); $this->impl->service(); - $this->impl->shutdown(); } } \ No newline at end of file From 0ff034a30b4631e76b58f63ff7899a9049770a18 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 11 May 2025 10:47:24 +0200 Subject: [PATCH 75/79] Fix tests for forwarding messages and requests with workers --- src/test/php/web/unittest/Channel.class.php | 5 ++- .../server/ForwardMessagesTest.class.php | 33 ++++++++++--------- .../server/ForwardRequestsTest.class.php | 4 +-- 3 files changed, 21 insertions(+), 21 deletions(-) diff --git a/src/test/php/web/unittest/Channel.class.php b/src/test/php/web/unittest/Channel.class.php index 0e1aad7c..d0ed416f 100755 --- a/src/test/php/web/unittest/Channel.class.php +++ b/src/test/php/web/unittest/Channel.class.php @@ -3,10 +3,9 @@ use peer\{Socket, SocketEndpoint}; class Channel extends Socket { - public $in, $out; - public $closed= false; + public $in, $out, $closed; - public function __construct($chunks) { $this->in= $chunks; } + public function __construct($chunks, $connected= false) { $this->in= $chunks; $this->closed= !$connected; } public function connect($timeout= 2.0) { $this->closed= false; } diff --git a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php index 133c9757..eef887dc 100755 --- a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php +++ b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php @@ -14,9 +14,15 @@ private function message(...$lines): string { return implode("\r\n", $lines); } + /** @return void */ + private function forward(Channel $ws, Channel $backend, $payload) { + $conn= new Connection($ws, self::WSID, null, '/ws', []); + foreach ((new ForwardMessages([new Worker(null, $backend)]))->message($conn, $payload) ?? [] as $_) { } + } + #[Test] public function can_create() { - new ForwardMessages(new Worker(null, new Channel([]))); + new ForwardMessages([new Worker(null, new Channel([]))]); } #[Test, Values(["d\r\ndata: Tested\n\r\n0\r\n\r\n", "19\r\nevent: text\ndata: Tested\n\r\n0\r\n\r\n"])] @@ -39,9 +45,8 @@ public function text($payload) { ); $backend= new Channel([$response]); - $ws= new Channel([]); - $fixture= new ForwardMessages(new Worker(null, $backend)); - $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); + $ws= new Channel([], true); + $this->forward($ws, $backend, 'Test'); Assert::equals($request, implode('', $backend->out)); Assert::equals("\201\006Tested", implode('', $ws->out)); @@ -68,9 +73,8 @@ public function binary() { ); $backend= new Channel([$response]); - $ws= new Channel([]); - $fixture= new ForwardMessages(new Worker(null, $backend)); - $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); + $ws= new Channel([], true); + $this->forward($ws, $backend, new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); Assert::equals("\202\002\047\011", implode('', $ws->out)); @@ -97,9 +101,8 @@ public function close() { ); $backend= new Channel([$response]); - $ws= new Channel([]); - $fixture= new ForwardMessages(new Worker(null, $backend)); - $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), new Bytes([8, 15])); + $ws= new Channel([], true); + $this->forward($ws, $backend, new Bytes([8, 15])); Assert::equals($request, implode('', $backend->out)); Assert::equals("\210\007\003\363Error", implode('', $ws->out)); @@ -126,9 +129,8 @@ public function unexpected_type() { ); $backend= new Channel([$response]); - $ws= new Channel([]); - $fixture= new ForwardMessages(new Worker(null, $backend)); - $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); + $ws= new Channel([], true); + $this->forward($ws, $backend, 'Test'); Assert::equals($request, implode('', $backend->out)); Assert::equals("\210\056\003\363Unexpected event from backend:///ws: unknown", implode('', $ws->out)); @@ -155,9 +157,8 @@ public function backend_error() { ); $backend= new Channel([$response]); - $ws= new Channel([]); - $fixture= new ForwardMessages(new Worker(null, $backend)); - $fixture->message(new Connection($ws, self::WSID, null, '/ws', []), 'Test'); + $ws= new Channel([], true); + $this->forward($ws, $backend, 'Test'); Assert::equals($request, implode('', $backend->out)); Assert::equals("\210\060\003\363Unexpected status code from backend:///ws: 500", implode('', $ws->out)); diff --git a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php index 42c786c1..3b0bd5d0 100755 --- a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -14,12 +14,12 @@ private function message(...$lines): string { /** @return void */ private function forward(Channel $client, Channel $backend) { - foreach ((new ForwardRequests(new Worker(null, $backend)))->handleData($client) ?? [] as $_) { } + foreach ((new ForwardRequests([new Worker(null, $backend)]))->handleData($client) ?? [] as $_) { } } #[Test] public function can_create() { - new ForwardRequests(new Worker(null, new Channel([]))); + new ForwardRequests([new Worker(null, new Channel([]))]); } #[Test] From ce26d463d354943dd4231c0228cac60651df3242 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 11 May 2025 10:54:10 +0200 Subject: [PATCH 76/79] Add test for request distribution --- .../server/ForwardRequestsTest.class.php | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php index 3b0bd5d0..58d395d0 100755 --- a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -157,4 +157,28 @@ public function write($chunk) { Assert::false($backend->isConnected()); } + + #[Test] + public function distribute_request_to_first_idle_backend() { + $request= $this->message( + 'GET / HTTP/1.0', + '', + '', + ); + $response= $this->message( + 'HTTP/1.0 204 No content', + 'Content-Length: 0', + '', + '', + ); + $client= new Channel([$request]); + $backends= ['busy' => new Channel([], true), 'idle' => new Channel([$response], false)]; + + $workers= [new Worker(null, $backends['busy']), new Worker(null, $backends['idle'])]; + foreach ((new ForwardRequests($workers))->handleData($client) ?? [] as $_) { } + + Assert::null($backends['busy']->out); + Assert::equals($request, implode('', $backends['idle']->out)); + Assert::equals($response, implode('', $client->out)); + } } \ No newline at end of file From 9972b9512de7a21c8d768a396013f4d2eb64a6f4 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 11 May 2025 10:57:42 +0200 Subject: [PATCH 77/79] Remove `sequential` server model See https://github.com/xp-forge/web/pull/121#discussion_r2041134468 --- README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.md b/README.md index 243a0ef9..354af0d0 100755 --- a/README.md +++ b/README.md @@ -52,7 +52,6 @@ Server models The four server models (*selectable via `-m ` on the command line*) are: * **async** (*the default since 3.0.0*): A single-threaded web server. Handlers can yield control back to the server to serve other clients during lengthy operations such as file up- and downloads. -* **sequential**: Same as above, but blocks until one client's HTTP request handler has finished executing before serving the next request. * **prefork**: Much like Apache, forks a given number of children to handle HTTP requests. Requires the `pcntl` extension. * **develop**: As mentioned above, built ontop of the PHP development wenserver. Application code is recompiled and application setup performed from scratch on every request, errors and debug output are handled by the [development console](https://github.com/xp-forge/web/pull/35). From 81d6dbb9ed075436522bc11b621242ad3de3758c Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sun, 11 May 2025 19:53:57 +0200 Subject: [PATCH 78/79] Add test waits_for_worker_to_become_idle() --- .../server/ForwardRequestsTest.class.php | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php index 58d395d0..a2d8c00e 100755 --- a/src/test/php/web/unittest/server/ForwardRequestsTest.class.php +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -181,4 +181,31 @@ public function distribute_request_to_first_idle_backend() { Assert::equals($request, implode('', $backends['idle']->out)); Assert::equals($response, implode('', $client->out)); } + + #[Test] + public function waits_for_worker_to_become_idle() { + $request= $this->message( + 'GET / HTTP/1.0', + '', + '', + ); + $response= $this->message( + 'HTTP/1.0 204 No content', + 'Content-Length: 0', + '', + '', + ); + $client= new Channel([$request]); + $backend= new Channel([$response], true); + $workers= [new Worker(null, $backend)]; + + foreach ((new ForwardRequests($workers))->handleData($client) ?? [] as $event => $arguments) { + + // Close connection to mark backend as idle + if ('delay' === $event) $backend->close(); + } + + Assert::equals($request, implode('', $backend->out)); + Assert::equals($response, implode('', $client->out)); + } } \ No newline at end of file From 28ceba1c1a4d3e365927ec07174610c3506f1cb7 Mon Sep 17 00:00:00 2001 From: Timm Friebe Date: Sat, 5 Jul 2025 10:27:19 +0200 Subject: [PATCH 79/79] Test named argument selection --- src/main/php/xp/web/Servers.class.php | 8 ++++---- src/test/php/web/unittest/server/ServersTest.class.php | 10 ++++++++++ 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/src/main/php/xp/web/Servers.class.php b/src/main/php/xp/web/Servers.class.php index 0507738b..226ea882 100755 --- a/src/main/php/xp/web/Servers.class.php +++ b/src/main/php/xp/web/Servers.class.php @@ -21,14 +21,14 @@ public function newInstance($address, $arguments= []) { return new Standalone($address, new PreforkingServer( null, null, - $this->select($arguments, 'children') ?? $arguments[0] ?? 10 + self::argument($arguments, 'children') ?? $arguments[0] ?? 10 )); } }; self::$DEVELOP= new class(2, 'DEVELOP') extends Servers { static function __static() { } public function newInstance($address, $arguments= []) { - return new Develop($address, $this->select($arguments, 'workers') ?? $arguments[0] ?? 1); + return new Develop($address, self::argument($arguments, 'workers') ?? $arguments[0] ?? 1); } }; } @@ -40,8 +40,8 @@ public function newInstance($address, $arguments= []) { * @param string $name * @return ?string */ - protected function select($arguments, $name) { - foreach ($arguments as $i => $argument) { + public static function argument($arguments, $name) { + foreach ($arguments as $argument) { if (false !== ($p= strpos($argument, '=')) && 0 === strncmp($argument, $name, $p)) { return substr($argument, $p + 1); } diff --git a/src/test/php/web/unittest/server/ServersTest.class.php b/src/test/php/web/unittest/server/ServersTest.class.php index 03e78b1d..09d9e614 100755 --- a/src/test/php/web/unittest/server/ServersTest.class.php +++ b/src/test/php/web/unittest/server/ServersTest.class.php @@ -66,4 +66,14 @@ public function supports_ipv6_notation() { public function supports_ipv6_notation_with_port() { Assert::equals(8080, Servers::named('serve')->newInstance('[::1]:8080')->port()); } + + #[Test] + public function select_named_argument() { + Assert::equals('1', Servers::argument(['name=test', 'workers=1'], 'workers')); + } + + #[Test] + public function select_non_existant_named_argument() { + Assert::null(Servers::argument([], 'workers')); + } } \ No newline at end of file