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). diff --git a/composer.json b/composer.json index ad6e6e8e..43f69a3f 100755 --- a/composer.json +++ b/composer.json @@ -7,8 +7,9 @@ "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", + "xp-framework/networking": "^10.1", + "xp-forge/uri": "^3.1", + "xp-forge/websockets": "^4.1", "php": ">=7.4.0" }, "require-dev" : { diff --git a/src/it/php/web/unittest/IntegrationTest.class.php b/src/it/php/web/unittest/IntegrationTest.class.php index a5469329..3096af75 100755 --- a/src/it/php/web/unittest/IntegrationTest.class.php +++ b/src/it/php/web/unittest/IntegrationTest.class.php @@ -1,10 +1,13 @@ 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])]; } /** @@ -161,4 +165,34 @@ public function with_large_cookie($length) { $r= $this->send('GET', '/cookie', '1.0', ['Cookie' => $header]); Assert::equals((string)strlen($header), $r['body']); } + + #[Test, Values(from: 'messages')] + public function websocket_message($input, $output) { + try { + $ws= new WebSocket($this->server->connection, '/ws'); + $ws->connect(); + $ws->send($input); + $result= $ws->receive(); + } finally { + $ws->close(); + } + Assert::equals($output, $result); + } + + #[Test, Expect(class: ProtocolException::class, message: 'Connection closed (#1007): Not valid utf-8')] + public function invalid_utf8_passed_to_websocket_text_message() { + try { + $ws= new WebSocket($this->server->connection, '/ws'); + $ws->connect(); + $ws->send("\xfc"); + $ws->receive(); + } finally { + $ws->close(); + } + } + + #[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 db69eae6..78d71714 100755 --- a/src/it/php/web/unittest/TestingApplication.class.php +++ b/src/it/php/web/unittest/TestingApplication.class.php @@ -1,7 +1,9 @@ new WebSocket(function($conn, $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'); $res->send('Answered with status 420', 'text/plain'); @@ -20,7 +29,7 @@ public function routes() { }, '/raise/exception' => 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'); diff --git a/src/it/php/web/unittest/TestingServer.class.php b/src/it/php/web/unittest/TestingServer.class.php index 04b1a595..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\HttpProtocol; +use xp\web\srv\{Protocol, HttpProtocol, WebSocketProtocol}; /** * 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, HttpProtocol::executing($application, new Logging(null))); + $s->listen($socket, Protocol::multiplex() + ->serving('http', new HttpProtocol($application, $log)) + ->serving('websocket', new WebSocketProtocol(null, $log)) + ); $s->init(); Console::writeLinef('+ Service %s:%d', $socket->host, $socket->port); $s->service(); diff --git a/src/main/php/web/Application.class.php b/src/main/php/web/Application.class.php index 8b6df734..7aabce4d 100755 --- a/src/main/php/web/Application.class.php +++ b/src/main/php/web/Application.class.php @@ -80,13 +80,14 @@ public function install($arg) { * * @param web.Request $request * @param web.Response $response - * @return var + * @return iterable */ public function service($request, $response) { $seen= []; // Handle dispatching dispatch: $result= $this->routing()->handle($request, $response); + $return= null; if ($result instanceof Traversable) { foreach ($result as $kind => $argument) { if ('dispatch' === $kind) { @@ -96,10 +97,16 @@ 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; + } else { + yield $kind => $argument; } - yield $kind => $argument; } } + return $return; } /** @return string */ diff --git a/src/main/php/web/Logging.class.php b/src/main/php/web/Logging.class.php index 803c57a5..67baa300 100755 --- a/src/main/php/web/Logging.class.php +++ b/src/main/php/web/Logging.class.php @@ -58,15 +58,33 @@ public function tee($sink) { } /** - * Writes a log entry + * Writes an 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= []) { + $this->sink && $this->sink->log( + $response->status(), + $request->method(), + $request->uri()->resource(), + $response->trace + $hints + ); + } + + /** + * Writes a log entry + * + * @param string $status + * @param string $method + * @param string $resource + * @param [:var] $hints Optional hints + * @return void + */ + public function log($status, $method, $resource, $hints= []) { + $this->sink && $this->sink->log($status, $method, $resource, $hints); } /** diff --git a/src/main/php/web/Request.class.php b/src/main/php/web/Request.class.php index 0f9088db..c3a3406a 100755 --- a/src/main/php/web/Request.class.php +++ b/src/main/php/web/Request.class.php @@ -30,7 +30,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/handler/WebSocket.class.php b/src/main/php/web/handler/WebSocket.class.php new file mode 100755 index 00000000..8d3cdb1a --- /dev/null +++ b/src/main/php/web/handler/WebSocket.class.php @@ -0,0 +1,70 @@ +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: // RFC 6455 + $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 9: // Reserved version, use for WS <-> SSE translation + $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); + foreach ($events->receive() as $message) { + $this->listener->message($events, $message); + } + return; + + 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', [ + 'path' => $request->uri()->resource(), + '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 new file mode 100755 index 00000000..f9ec4a2b --- /dev/null +++ b/src/main/php/web/io/EventSink.class.php @@ -0,0 +1,61 @@ +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; + case 'application/octet-stream': yield Opcodes::BINARY => new Bytes(Streams::readAll($this->request->stream())); break; + default: throw new IllegalStateException('Unexpected content type '.$mime); + } + } + + /** + * 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"); + } else { + $this->out->write("data: ".addcslashes($message, "\r\n")."\n\n"); + } + } + + /** + * 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"); + } +} \ 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..519124fb --- /dev/null +++ b/src/main/php/web/io/EventSource.class.php @@ -0,0 +1,34 @@ +reader= new StringReader($in); + } + + /** Yields events and associated data */ + public function getIterator(): Traversable { + $event= null; + while (null !== ($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/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/ReadChunks.class.php b/src/main/php/web/io/ReadChunks.class.php index 4b70145b..6212c8fe 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/main/php/web/io/ReadLength.class.php b/src/main/php/web/io/ReadLength.class.php index 3055ebda..3fe14f6c 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/main/php/web/io/TestInput.class.php b/src/main/php/web/io/TestInput.class.php index 1ef62aaa..5aae0115 100755 --- a/src/main/php/web/io/TestInput.class.php +++ b/src/main/php/web/io/TestInput.class.php @@ -6,20 +6,20 @@ * @test 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/web/logging/Sink.class.php b/src/main/php/web/logging/Sink.class.php index afc61090..2b44b242 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 $resource * @param [:var] $hints Optional hints * @return void */ - public abstract function log($request, $response, $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 7ee8ecde..80b4f2b4 100755 --- a/src/main/php/web/logging/ToAllOf.class.php +++ b/src/main/php/web/logging/ToAllOf.class.php @@ -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 $resource * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { + public function log($status, $method, $resource, $hints) { foreach ($this->sinks as $sink) { - $sink->log($request, $response, $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 9d5aa134..d6ecf137 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 $resource * @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, $resource, $hints) { if ($hints) { - $this->cat->warn($response->status(), $request->method(), $uri, $hints); + $this->cat->warn($status, $method, $resource, $hints); } else { - $this->cat->info($response->status(), $request->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 cc98145f..a543b1ad 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 $resource * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $query= $request->uri()->query(); + public function log($status, $method, $resource, $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, + $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 686d0c54..ee9d2da0 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 $resource * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $query= $request->uri()->query(); + public function log($status, $method, $resource, $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, + $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 86e70601..70e3a2f7 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 $resource * @param [:var] $hints Optional hints * @return void */ - public function log($request, $response, $hints) { - $this->function->__invoke($request, $response, $hints); + public function log($status, $method, $resource, $hints) { + $this->function->__invoke($status, $method, $resource, $hints); } } \ No newline at end of file diff --git a/src/main/php/xp/web/Runner.class.php b/src/main/php/xp/web/Runner.class.php index ca281dc8..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](http://php.net/features.commandline.webserver): + * - 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/SAPI.class.php b/src/main/php/xp/web/SAPI.class.php index 1064a4d6..bcf9d80c 100755 --- a/src/main/php/xp/web/SAPI.class.php +++ b/src/main/php/xp/web/SAPI.class.php @@ -58,7 +58,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/Servers.class.php b/src/main/php/xp/web/Servers.class.php index b4daf87e..226ea882 100755 --- a/src/main/php/xp/web/Servers.class.php +++ b/src/main/php/xp/web/Servers.class.php @@ -1,7 +1,7 @@ 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) { diff --git a/src/main/php/xp/web/srv/Develop.class.php b/src/main/php/xp/web/srv/Develop.class.php index 26a1d7d4..53c6d8fc 100755 --- a/src/main/php/xp/web/srv/Develop.class.php +++ b/src/main/php/xp/web/srv/Develop.class.php @@ -1,14 +1,25 @@ workers= $workers; + } /** * Serve requests @@ -31,29 +42,6 @@ public function serve($source, $profile, $webroot, $docroot, $config, $args, $lo $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', ('localhost' === $this->host ? '127.0.0.1' : $this->host).':'.$this->port, '-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')] - )); - // Export environment putenv('DOCUMENT_ROOT='.$docroot); putenv('SERVER_PROFILE='.$profile); @@ -63,52 +51,60 @@ 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"); - 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]))) { - throw new IOException('Cannot execute `'.$runtime->getExecutable()->getFileName().'`'); + $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; 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(), - proc_get_status($proc)['pid'] + $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($backends)) + ->serving('websocket', new WebSocketProtocol(new ForwardMessages($backends))) ); - // Inside `xp -supervise`, connect to signalling socket + // 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')) { - $s= new Socket('127.0.0.1', $port); - $s->connect(); - $s->canRead(null) && $s->read(); - $s->close(); - } else { - Console::read(); - Console::write('> Shut down '); + $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(); + }); } - // 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'), ')'); + 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 new file mode 100755 index 00000000..3cf63fdd --- /dev/null +++ b/src/main/php/xp/web/srv/ForwardMessages.class.php @@ -0,0 +1,77 @@ +path()} HTTP/1.1\r\n"; + $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"; + } + + // Wait briefly before retrying to find an available worker + while (null === ($backend= $this->select())) { + yield 'delay' => 1; + } + + try { + $backend->connect(); + $backend->write($request."\r\n".$message); + + $response= new Input($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 'text': case null: $conn->send($value); break; + case 'bytes': $conn->send(new Bytes($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 from backend://'.$conn->path().': '.$event); + } + } + } catch (Any $e) { + $conn->answer(Opcodes::CLOSE, pack('na*', 1011, $e->getMessage())); + $conn->close(); + } finally { + $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 new file mode 100755 index 00000000..b881a981 --- /dev/null +++ b/src/main/php/xp/web/srv/ForwardRequests.class.php @@ -0,0 +1,106 @@ +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 + * + * @param peer.Socket $socket + * @return iterable + */ + public function handleData($socket) { + static $exclude= ['Remote-Addr' => true]; + + $request= new Input($socket); + yield from $request->consume(); + + if (Input::REQUEST === $request->kind) { + + // 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= []; + foreach ($request->headers() as $name => $value) { + isset($exclude[$name]) || $message.= "{$name}: {$value}\r\n"; + $headers[$name]= $value; + } + // \util\cmd\Console::writeLine('>>> ', $message); + $backend->write($message."\r\n"); + foreach ($this->transmit($request->incoming(), $backend) as $_) { + yield 'read' => null; + } + + $response= new Input($backend); + foreach ($response->consume() as $_) { } + + // Switch protocols + if (101 === $response->status()) { + $result= ['websocket', ['path' => $request->resource(), 'headers' => $headers]]; + } else { + $result= null; + } + + 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"; + } + // \util\cmd\Console::writeLine('<<< ', $message); + $socket->write($message."\r\n"); + + foreach ($this->transmit($response->incoming(), $socket) as $_) { + yield 'write' => null; + } + } finally { + $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/HttpProtocol.class.php b/src/main/php/xp/web/srv/HttpProtocol.class.php index b9728697..b8d88c81 100755 --- a/src/main/php/xp/web/srv/HttpProtocol.class.php +++ b/src/main/php/xp/web/srv/HttpProtocol.class.php @@ -2,17 +2,11 @@ use Throwable; use lang\ClassLoader; -use peer\server\{AsyncServer, ServerProtocol}; use web\{Error, InternalServerError, Request, Response, Headers, Status}; -/** - * HTTP protocol implementation - * - * @test web.unittest.HttpProtocolTest - */ -class HttpProtocol implements ServerProtocol { +/** @test web.unittest.server.HttpProtocolTest */ +class HttpProtocol extends Switchable { private $application, $logging; - public $server= null; private $close= false; /** @@ -21,34 +15,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 * @@ -58,9 +29,7 @@ public function handleData($socket) { * @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()); @@ -77,7 +46,7 @@ private function sendError($request, $response, $error) { break; } } - $this->logging->log($request, $response, $response->trace + ['error' => $error]); + $this->logging->exchange($request, $response, ['error' => $error]); } /** @@ -90,24 +59,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 +91,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'); @@ -152,9 +106,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) { @@ -168,7 +122,7 @@ public function handleData($socket) { clearstatcache(); \xp::gc(); } - return; + return $result; } // Handle request errors and close the socket @@ -184,15 +138,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/Input.class.php b/src/main/php/xp/web/srv/Input.class.php index cf09d1c5..19e44a5c 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, $resource, $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->resource, $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 */ - public function uri() { return $this->uri; } + /** @return string */ + public function resource() { return $this->resource; } + + /** @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/Protocol.class.php b/src/main/php/xp/web/srv/Protocol.class.php new file mode 100755 index 00000000..7e2ff9ce --- /dev/null +++ b/src/main/php/xp/web/srv/Protocol.class.php @@ -0,0 +1,96 @@ +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 + * @return void + */ + public function handleConnect($socket) { + $this->protocols[spl_object_id($socket)]= current($this->protocols); + } + + /** + * Handle client data + * + * @param peer.Socket $socket + * @return iterable + */ + 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 + * @return void + */ + public function handleDisconnect($socket) { + $handle= spl_object_id($socket); + if (isset($this->protocols[$handle])) { + $this->protocols[$handle]->handleDisconnect($socket); + unset($this->protocols[$handle]); + } + $socket->close(); + } + + /** + * Handle I/O error + * + * @param peer.Socket $socket + * @param lang.XPException $e + * @return void + */ + public function handleError($socket, $e) { + $handle= spl_object_id($socket); + if (isset($this->protocols[$handle])) { + $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..eb45e68a 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 * @@ -60,7 +37,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 WebSocketProtocol(null, $environment->logging())) + ); $this->impl->init(); Console::writeLine("\e[33m@", nameof($this), '(HTTP @ ', $socket->toString(), ")\e[0m"); @@ -78,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 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/WebSocketProtocol.class.php b/src/main/php/xp/web/srv/WebSocketProtocol.class.php new file mode 100755 index 00000000..22dd9b09 --- /dev/null +++ b/src/main/php/xp/web/srv/WebSocketProtocol.class.php @@ -0,0 +1,128 @@ +listener= $listener; + $this->logging= $logging ?? new Logging(null); + } + + /** + * 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'] ?? $this->listener, + $context['path'], + $context['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 $opcode => $payload) { + try { + switch ($opcode) { + case Opcodes::TEXT: + if (!preg_match('//u', $payload)) { + $conn->answer(Opcodes::CLOSE, pack('na*', 1007, 'Not valid utf-8')); + $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) { + $hints= ['error' => Throwable::wrap($e)]; + } + + $this->logging->log('WS', Opcodes::nameOf($opcode), $conn->path(), $hints); + } + } + + /** + * 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/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..5c7020a6 --- /dev/null +++ b/src/main/php/xp/web/srv/Workers.class.php @@ -0,0 +1,86 @@ +path; + } + } + + $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'); + } + + /** + * Launches a worker and returns it. + * + * @throws io.IOException + * @return xp.web.srv.Worker + */ + public function launch() { + + // 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'; + } + + $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.'`'); + } + + // Parse `[...] PHP 8.3.15 Development Server (http://127.0.0.1:60922) started` + $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: `'.implode('', $lines).'`'); + } + + return new Worker($proc, new Socket($matches[1], (int)$matches[2])); + } +} \ No newline at end of file diff --git a/src/test/php/web/unittest/Channel.class.php b/src/test/php/web/unittest/Channel.class.php index 13ef0867..d0ed416f 100755 --- a/src/test/php/web/unittest/Channel.class.php +++ b/src/test/php/web/unittest/Channel.class.php @@ -1,15 +1,22 @@ in= $chunks; } + public function __construct($chunks, $connected= false) { $this->in= $chunks; $this->closed= !$connected; } + + 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 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/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/handler/WebSocketTest.class.php b/src/test/php/web/unittest/handler/WebSocketTest.class.php new file mode 100755 index 00000000..392ec832 --- /dev/null +++ b/src/test/php/web/unittest/handler/WebSocketTest.class.php @@ -0,0 +1,77 @@ +send(new Bytes("\x08\x15{$payload}")); + } else { + $conn->send('Re: '.$payload); + } + }; + (new WebSocket($echo))->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 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'))); + 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 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..2c518c1a --- /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'], + iterator_to_array((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")], + iterator_to_array((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 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..8d45ea25 --- /dev/null +++ b/src/test/php/web/unittest/io/EventSourceTest.class.php @@ -0,0 +1,36 @@ + '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(new MemoryInputStream('')); + } + + #[Test, Values(from: 'inputs')] + public function events($lines, $expected) { + $events= new EventSource(new MemoryInputStream(implode("\n", $lines))); + $actual= []; + foreach ($events as $type => $event) { + $actual[]= [$type => $event]; + } + Assert::equals($expected, $actual); + } +} \ No newline at end of file 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] 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] 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/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 diff --git a/src/test/php/web/unittest/server/ForwardMessagesTest.class.php b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php new file mode 100755 index 00000000..eef887dc --- /dev/null +++ b/src/test/php/web/unittest/server/ForwardMessagesTest.class.php @@ -0,0 +1,167 @@ +message($conn, $payload) ?? [] as $_) { } + } + + #[Test] + public function can_create() { + 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"])] + public function text($payload) { + $request= $this->message( + 'POST /ws HTTP/1.1', + 'Sec-WebSocket-Version: 9', + 'Sec-WebSocket-Id: '.self::WSID, + 'Content-Type: text/plain', + 'Content-Length: 4', + '', + 'Test', + ); + $response= $this->message( + 'HTTP/1.1 200 OK', + 'Content-Type: text/event-stream', + 'Transfer-Encoding: chunked', + '', + $payload + ); + + $backend= new Channel([$response]); + $ws= new Channel([], true); + $this->forward($ws, $backend, 'Test'); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\201\006Tested", implode('', $ws->out)); + Assert::true($ws->isConnected()); + } + + #[Test] + 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', + '', + "\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([], true); + $this->forward($ws, $backend, new Bytes([8, 15])); + + Assert::equals($request, implode('', $backend->out)); + Assert::equals("\202\002\047\011", implode('', $ws->out)); + Assert::true($ws->isConnected()); + } + + #[Test] + 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', + '', + "\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([], true); + $this->forward($ws, $backend, 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 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', + '', + '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([], 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)); + Assert::false($ws->isConnected()); + } + + #[Test] + 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', + '', + '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([], 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)); + Assert::false($ws->isConnected()); + } +} \ No newline at end of file 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..a2d8c00e --- /dev/null +++ b/src/test/php/web/unittest/server/ForwardRequestsTest.class.php @@ -0,0 +1,211 @@ +handleData($client) ?? [] as $_) { } + } + + #[Test] + public function can_create() { + new ForwardRequests([new Worker(null, 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)); + } + + #[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()); + } + + #[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)); + } + + #[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 diff --git a/src/test/php/web/unittest/HttpProtocolTest.class.php b/src/test/php/web/unittest/server/HttpProtocolTest.class.php similarity index 83% rename from src/test/php/web/unittest/HttpProtocolTest.class.php rename to src/test/php/web/unittest/server/HttpProtocolTest.class.php index 773f6653..99e81bb3 100755 --- a/src/test/php/web/unittest/HttpProtocolTest.class.php +++ b/src/test/php/web/unittest/server/HttpProtocolTest.class.php @@ -1,15 +1,17 @@ -log= new Logging(null); } @@ -56,12 +58,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 +76,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 +90,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 +104,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 +122,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 +136,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,14 +150,14 @@ 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'); 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( @@ -173,11 +175,11 @@ 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); }), - 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/server/InputTest.class.php b/src/test/php/web/unittest/server/InputTest.class.php index 992ff588..fcb4f06f 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->resource()); + 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] @@ -136,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 d0fed55d..3558f369 100755 --- a/src/test/php/web/unittest/server/SAPITest.class.php +++ b/src/test/php/web/unittest/server/SAPITest.class.php @@ -93,9 +93,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] diff --git a/src/test/php/web/unittest/server/ServersTest.class.php b/src/test/php/web/unittest/server/ServersTest.class.php index 936fab7f..09d9e614 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]; @@ -67,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 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..21fe8bb7 --- /dev/null +++ b/src/test/php/web/unittest/server/WebSocketProtocolTest.class.php @@ -0,0 +1,111 @@ +handleSwitch($socket, ['path' => '/ws', 'headers' => []]); + try { + foreach ($protocol->handleData($socket) as $_) { } + } finally { + $protocol->handleDisconnect($socket); + } + + return $socket->out; + } + + #[Before] + public function noop() { + $this->noop= function($conn, $message) { + // NOOP + }; + } + + #[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 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", "\x82\x02\x47\x11"], $out); + } + + #[Test] + public function answers_ping_with_pong_automatically() { + $out= $this->handle(["\x89\x04", "Test"], $this->noop); + Assert::equals(["\x8a\x04Test"], $out); + } + + #[Test] + public function default_close() { + $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"], $this->noop); + Assert::equals(["\x88\x06\x03\xe8Test"], $out); + } + + #[Test] + 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 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..be1e70df --- /dev/null +++ b/src/test/php/web/unittest/server/WorkersTest.class.php @@ -0,0 +1,39 @@ +worker= (new Workers('.', []))->launch(); + } + + #[Test] + public function running() { + Assert::true($this->worker->running()); + } + + #[Test] + public function pid() { + Assert::notEquals(null, $this->worker->pid()); + } + + #[Test] + public function execute_http_requests() { + $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