├── .github └── workflows │ └── ci.yml ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── composer-require-check.json ├── composer.json ├── phpunit.xml.dist ├── psalm.xml ├── src ├── Rfc6455Connection.php ├── Rfc6455ConnectionFactory.php ├── Rfc6455Connector.php ├── WebsocketConnectException.php ├── WebsocketConnection.php ├── WebsocketConnectionFactory.php ├── WebsocketConnector.php ├── WebsocketHandshake.php └── functions.php └── test-autobahn ├── config └── fuzzingserver.json └── runner.php /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Continuous Integration 2 | 3 | on: 4 | - push 5 | - pull_request 6 | 7 | jobs: 8 | tests: 9 | strategy: 10 | matrix: 11 | include: 12 | - operating-system: 'ubuntu-latest' 13 | php-version: '8.1' 14 | 15 | - operating-system: 'ubuntu-latest' 16 | php-version: '8.2' 17 | 18 | - operating-system: 'ubuntu-latest' 19 | php-version: '8.3' 20 | 21 | - operating-system: 'ubuntu-latest' 22 | php-version: '8.4' 23 | style-fix: none 24 | static-analysis: none 25 | 26 | - operating-system: 'windows-latest' 27 | php-version: '8.3' 28 | job-description: 'on Windows' 29 | 30 | - operating-system: 'macos-latest' 31 | php-version: '8.3' 32 | job-description: 'on macOS' 33 | 34 | name: PHP ${{ matrix.php-version }} ${{ matrix.job-description }} 35 | 36 | runs-on: ${{ matrix.operating-system }} 37 | 38 | steps: 39 | - name: Set git to use LF 40 | run: | 41 | git config --global core.autocrlf false 42 | git config --global core.eol lf 43 | 44 | - name: Checkout code 45 | uses: actions/checkout@v2 46 | 47 | - name: Setup PHP 48 | uses: shivammathur/setup-php@v2 49 | with: 50 | php-version: ${{ matrix.php-version }} 51 | 52 | - name: Get Composer cache directory 53 | id: composer-cache 54 | run: echo "::set-output name=dir::$(composer config cache-dir)" 55 | 56 | - name: Cache dependencies 57 | uses: actions/cache@v2 58 | with: 59 | path: ${{ steps.composer-cache.outputs.dir }} 60 | key: composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}-${{ matrix.composer-flags }} 61 | restore-keys: | 62 | composer-${{ runner.os }}-${{ matrix.php-version }}-${{ hashFiles('**/composer.*') }}- 63 | composer-${{ runner.os }}-${{ matrix.php-version }}- 64 | composer-${{ runner.os }}- 65 | composer- 66 | 67 | - name: Install dependencies 68 | uses: nick-invision/retry@v2 69 | with: 70 | timeout_minutes: 5 71 | max_attempts: 5 72 | retry_wait_seconds: 30 73 | command: | 74 | composer update --optimize-autoloader --no-interaction --no-progress ${{ matrix.composer-flags }} 75 | composer info -D 76 | 77 | - name: Run tests 78 | run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 79 | 80 | - name: Run static analysis 81 | run: vendor/bin/psalm.phar 82 | if: matrix.static-analysis != 'none' 83 | 84 | - name: Run style fixer 85 | env: 86 | PHP_CS_FIXER_IGNORE_ENV: 1 87 | run: vendor/bin/php-cs-fixer --diff --dry-run -v fix 88 | if: runner.os != 'Windows' && matrix.style-fix != 'none' 89 | 90 | - name: Install composer-require-checker 91 | run: php -r 'file_put_contents("composer-require-checker.phar", file_get_contents("https://github.com/maglnet/ComposerRequireChecker/releases/download/3.7.0/composer-require-checker.phar"));' 92 | if: runner.os != 'Windows' && matrix.composer-require-checker-version != 'none' 93 | 94 | - name: Run composer-require-checker 95 | run: php composer-require-checker.phar check composer.json --config-file $PWD/composer-require-check.json 96 | if: runner.os != 'Windows' && matrix.composer-require-checker-version != 'none' 97 | 98 | - name: Autobahn 99 | if: runner.os == 'Linux' && matrix.php-version == '8.3' 100 | shell: 'script -q -e -c "bash {0}"' 101 | run: | 102 | docker run -ti -d --rm -v ${PWD}/test-autobahn/config:/config -v ${PWD}/test-autobahn/reports:/reports -p 9001:9001 --name fuzzingserver crossbario/autobahn-testsuite 103 | sleep 3 104 | php test-autobahn/runner.php 105 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Submitting useful bug reports 2 | 3 | Please search existing issues first to make sure this is not a duplicate. 4 | Every issue report has a cost for the developers required to field it; be 5 | respectful of others' time and ensure your report isn't spurious prior to 6 | submission. Please adhere to [sound bug reporting principles](http://www.chiark.greenend.org.uk/~sgtatham/bugs.html). 7 | 8 | ## Development ideology 9 | 10 | Truths which we believe to be self-evident: 11 | 12 | - **It's an asynchronous world.** Be wary of anything that undermines 13 | async principles. 14 | 15 | - **The answer is not more options.** If you feel compelled to expose 16 | new preferences to the user it's very possible you've made a wrong 17 | turn somewhere. 18 | 19 | - **There are no power users.** The idea that some users "understand" 20 | concepts better than others has proven to be, for the most part, false. 21 | If anything, "power users" are more dangerous than the rest, and we 22 | should avoid exposing dangerous functionality to them. 23 | 24 | ## Code style 25 | 26 | The AMPHP project adheres to the PSR-2 style guide. 27 | 28 | https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-2-coding-style-guide.md 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015-2022 amphp 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # websocket-client 2 | 3 | AMPHP is a collection of event-driven libraries for PHP designed with fibers and concurrency in mind. 4 | `amphp/websocket-client` provides an asynchronous WebSocket client for PHP based on Amp. 5 | Websockets are full-duplex communication channels, which are mostly used for realtime communication where the HTTP request / response cycle has too much overhead. 6 | They're also used if the server should be able to push data to the client without an explicit request. 7 | 8 | There are various use cases for a WebSocket client in PHP, such as consuming realtime APIs, writing tests for a WebSocket server, or controlling web browsers via their remote debugging APIs, which are based on WebSockets. 9 | 10 | ## Installation 11 | 12 | This package can be installed as a [Composer](https://getcomposer.org/) dependency. 13 | 14 | ``` 15 | composer require amphp/websocket-client 16 | ``` 17 | 18 | ## Requirements 19 | 20 | - PHP 8.1+ 21 | 22 | ## Usage 23 | 24 | ### Connecting 25 | 26 | You can create new WebSocket connections using `Amp\Websocket\connect()` or calling `connect()` on an instance of `WebsocketConnector`. 27 | The `connect()` function accepts a string, PSR-7 `UriInterface` instance, or a `WebsocketHandshake` as first argument. URIs must use the `ws` or `wss` (WebSocket over TLS) scheme. 28 | 29 | Custom connection parameters can be specified by passing a `WebsocketHandshake` object instead of a string as first argument, which can also be used to pass additional headers with the initial handshake. The second argument is an optional `Cancellation` which may be used to cancel the connection attempt. 30 | 31 | ```php 32 | connect($handshake); 78 | ``` 79 | 80 | ### Sending Data 81 | 82 | WebSocket messages can be sent using the `Connection::sendText()` and `Connection::sendBinary()` methods. 83 | Text messages sent with `Connection::sendText()` must be valid UTF-8. 84 | Binary messages send with `Connection::sendBinary()` can be arbitrary data. 85 | 86 | Both methods return as soon as the message has been fully written to the send buffer. This does not mean that the message is guaranteed to have been received by the other party. 87 | 88 | ### Receiving Data 89 | 90 | WebSocket messages can be received using the `Connection::receive()` method. `Connection::receive()` returns a `WebsocketMessage` instance once the client has started to receive a message. This allows streaming WebSocket messages, which might be pretty large. In practice, most messages are rather small, and it's fine buffering them completely by either calling `WebsocketMessage::buffer()` or casting the object to a string. The maximum length of a message is defined by the option given to the `WebsocketParserFactory` instance provided to the `WebsocketConnectionFactory` (10 MiB by default). 91 | 92 | ```php 93 | use Amp\Websocket\Client\WebsocketHandshake; 94 | use Amp\Websocket\WebsocketCloseCode; 95 | use function Amp\Websocket\Client\connect; 96 | 97 | // Connects to the websocket endpoint at libwebsockets.org 98 | // which sends a message every 50ms. 99 | $handshake = (new WebsocketHandshake('wss://libwebsockets.org')) 100 | ->withHeader('Sec-WebSocket-Protocol', 'dumb-increment-protocol'); 101 | 102 | $connection = connect($handshake); 103 | 104 | foreach ($connection as $message) { 105 | $payload = $message->buffer(); 106 | 107 | printf("Received: %s\n", $payload); 108 | 109 | if ($payload === '100') { 110 | $connection->close(); 111 | break; 112 | } 113 | } 114 | ``` 115 | 116 | ## Versioning 117 | 118 | `amphp/websocket-client` follows the [semver](http://semver.org/) semantic versioning specification like all other `amphp` packages. 119 | 120 | ## Security 121 | 122 | If you discover any security related issues, please use the private security issue reporter instead of using the public issue tracker. 123 | 124 | ## License 125 | 126 | The MIT License (MIT). Please see [`LICENSE`](./LICENSE) for more information. 127 | -------------------------------------------------------------------------------- /composer-require-check.json: -------------------------------------------------------------------------------- 1 | { 2 | "symbol-whitelist": [ 3 | "null", 4 | "true", 5 | "false", 6 | "static", 7 | "self", 8 | "parent", 9 | "array", 10 | "string", 11 | "int", 12 | "float", 13 | "bool", 14 | "iterable", 15 | "callable", 16 | "mixed", 17 | "void", 18 | "object", 19 | "websocketConnector" 20 | ], 21 | "php-core-extensions": [ 22 | "Core", 23 | "date", 24 | "pcre", 25 | "Phar", 26 | "Reflection", 27 | "SPL", 28 | "standard", 29 | "hash" 30 | ] 31 | } 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "amphp/websocket-client", 3 | "description": "Async WebSocket client for PHP based on Amp.", 4 | "license": "MIT", 5 | "authors": [ 6 | { 7 | "name": "Bob Weinand", 8 | "email": "bobwei9@hotmail.com" 9 | }, 10 | { 11 | "name": "Aaron Piotrowski", 12 | "email": "aaron@trowski.com" 13 | }, 14 | { 15 | "name": "Niklas Keller", 16 | "email": "me@kelunik.com" 17 | } 18 | ], 19 | "support": { 20 | "issues": "https://github.com/amphp/websocket-client/issues" 21 | }, 22 | "keywords": [ 23 | "async", 24 | "non-blocking", 25 | "websocket", 26 | "client", 27 | "http", 28 | "amp", 29 | "amphp" 30 | ], 31 | "require": { 32 | "php": ">=8.1", 33 | "amphp/amp": "^3", 34 | "amphp/byte-stream": "^2.1", 35 | "amphp/http": "^2.1", 36 | "amphp/http-client": "^5", 37 | "amphp/socket": "^2.2", 38 | "amphp/websocket": "^2", 39 | "league/uri": "^6.8|^7.1", 40 | "psr/http-message": "^1|^2", 41 | "revolt/event-loop": "^1" 42 | }, 43 | "require-dev": { 44 | "amphp/http-server": "^3", 45 | "amphp/websocket-server": "^3|^4", 46 | "amphp/phpunit-util": "^3", 47 | "amphp/php-cs-fixer-config": "^2", 48 | "phpunit/phpunit": "^9", 49 | "psr/log": "^1", 50 | "psalm/phar": "~5.26.1" 51 | }, 52 | "autoload": { 53 | "psr-4": { 54 | "Amp\\Websocket\\Client\\": "src" 55 | }, 56 | "files": [ 57 | "src/functions.php" 58 | ] 59 | }, 60 | "autoload-dev": { 61 | "psr-4": { 62 | "Amp\\Websocket\\Client\\": "test" 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /phpunit.xml.dist: -------------------------------------------------------------------------------- 1 | 2 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | test 23 | 24 | 25 | 26 | 27 | src 28 | 29 | 30 | 31 | -------------------------------------------------------------------------------- /psalm.xml: -------------------------------------------------------------------------------- 1 | 2 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/Rfc6455Connection.php: -------------------------------------------------------------------------------- 1 | 22 | */ 23 | final class Rfc6455Connection implements WebsocketConnection, \IteratorAggregate 24 | { 25 | use ForbidCloning; 26 | use ForbidSerialization; 27 | 28 | public const DEFAULT_MESSAGE_SIZE_LIMIT = (2 ** 20) * 10; // 10MB 29 | public const DEFAULT_FRAME_SIZE_LIMIT = (2 ** 20) * 10; // 10MB 30 | 31 | public function __construct( 32 | private readonly Rfc6455Client $client, 33 | private readonly Response $handshakeResponse, 34 | ) { 35 | } 36 | 37 | public function getHandshakeResponse(): Response 38 | { 39 | return $this->handshakeResponse; 40 | } 41 | 42 | public function receive(?Cancellation $cancellation = null): ?WebsocketMessage 43 | { 44 | return $this->client->receive($cancellation); 45 | } 46 | 47 | public function getId(): int 48 | { 49 | return $this->client->getId(); 50 | } 51 | 52 | public function getLocalAddress(): SocketAddress 53 | { 54 | return $this->client->getLocalAddress(); 55 | } 56 | 57 | public function getRemoteAddress(): SocketAddress 58 | { 59 | return $this->client->getRemoteAddress(); 60 | } 61 | 62 | public function getTlsInfo(): ?TlsInfo 63 | { 64 | return $this->client->getTlsInfo(); 65 | } 66 | 67 | public function getCloseInfo(): WebsocketCloseInfo 68 | { 69 | return $this->client->getCloseInfo(); 70 | } 71 | 72 | public function sendText(string $data): void 73 | { 74 | $this->client->sendText($data); 75 | } 76 | 77 | public function sendBinary(string $data): void 78 | { 79 | $this->client->sendBinary($data); 80 | } 81 | 82 | public function streamText(ReadableStream $stream): void 83 | { 84 | $this->client->streamText($stream); 85 | } 86 | 87 | public function streamBinary(ReadableStream $stream): void 88 | { 89 | $this->client->streamBinary($stream); 90 | } 91 | 92 | public function ping(): void 93 | { 94 | $this->client->ping(); 95 | } 96 | 97 | public function getCount(WebsocketCount $type): int 98 | { 99 | return $this->client->getCount($type); 100 | } 101 | 102 | public function getTimestamp(WebsocketTimestamp $type): float 103 | { 104 | return $this->client->getTimestamp($type); 105 | } 106 | 107 | public function isClosed(): bool 108 | { 109 | return $this->client->isClosed(); 110 | } 111 | 112 | public function close(int $code = WebsocketCloseCode::NORMAL_CLOSE, string $reason = ''): void 113 | { 114 | $this->client->close($code, $reason); 115 | } 116 | 117 | public function onClose(\Closure $onClose): void 118 | { 119 | $this->client->onClose($onClose); 120 | } 121 | 122 | public function isCompressionEnabled(): bool 123 | { 124 | return $this->client->isCompressionEnabled(); 125 | } 126 | 127 | public function getIterator(): Traversable 128 | { 129 | yield from $this->client; 130 | } 131 | } 132 | -------------------------------------------------------------------------------- /src/Rfc6455ConnectionFactory.php: -------------------------------------------------------------------------------- 1 | parserFactory, 42 | compressionContext: $compressionContext, 43 | heartbeatQueue: $this->heartbeatQueue, 44 | rateLimit: $this->rateLimit, 45 | frameSplitThreshold: $this->frameSplitThreshold, 46 | closePeriod: $this->closePeriod, 47 | ); 48 | 49 | return new Rfc6455Connection($client, $handshakeResponse); 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/Rfc6455Connector.php: -------------------------------------------------------------------------------- 1 | httpClient = $httpClient 38 | ?? (new HttpClientBuilder)->usingPool( 39 | new UnlimitedConnectionPool( 40 | new DefaultConnectionFactory(connectContext: (new ConnectContext)->withTcpNoDelay()) 41 | ) 42 | )->build(); 43 | } 44 | 45 | public function connect(WebsocketHandshake $handshake, ?Cancellation $cancellation = null): WebsocketConnection 46 | { 47 | $key = Websocket\generateKey(); 48 | $request = $this->generateRequest($handshake, $key); 49 | 50 | $deferred = new DeferredFuture(); 51 | $connectionFactory = $this->connectionFactory; 52 | $compressionContextFactory = $this->compressionContextFactory; 53 | 54 | $request->setUpgradeHandler(static function ( 55 | Socket $socket, 56 | Request $request, 57 | Response $response, 58 | ) use ( 59 | $connectionFactory, 60 | $compressionContextFactory, 61 | $deferred, 62 | $key, 63 | ): void { 64 | if (\strtolower($response->getHeader('upgrade') ?? '') !== 'websocket') { 65 | $deferred->error(new WebsocketConnectException('Upgrade header does not equal "websocket"', $response)); 66 | return; 67 | } 68 | 69 | if (!Websocket\validateAcceptForKey($response->getHeader('sec-websocket-accept') ?? '', $key)) { 70 | $deferred->error(new WebsocketConnectException('Invalid Sec-WebSocket-Accept header', $response)); 71 | return; 72 | } 73 | 74 | $extensions = Http\splitHeader($response, 'sec-websocket-extensions') ?? []; 75 | 76 | foreach ($extensions as $extension) { 77 | if ($compressionContext = $compressionContextFactory?->fromServerHeader($extension)) { 78 | break; 79 | } 80 | } 81 | 82 | $deferred->complete( 83 | $connectionFactory->createConnection($response, $socket, $compressionContext ?? null) 84 | ); 85 | }); 86 | 87 | $response = $this->httpClient->request($request, $cancellation); 88 | 89 | if ($response->getStatus() !== Http\HttpStatus::SWITCHING_PROTOCOLS) { 90 | throw new WebsocketConnectException(\sprintf( 91 | 'A %s (%d) response was not received; instead received response status: %s (%d)', 92 | Http\HttpStatus::getReason(Http\HttpStatus::SWITCHING_PROTOCOLS), 93 | Http\HttpStatus::SWITCHING_PROTOCOLS, 94 | $response->getReason(), 95 | $response->getStatus() 96 | ), $response); 97 | } 98 | 99 | return $deferred->getFuture()->await(); 100 | } 101 | 102 | private function generateRequest(WebsocketHandshake $handshake, string $key): Request 103 | { 104 | $uri = $handshake->getUri(); 105 | $uri = $uri->withScheme($uri->getScheme() === 'wss' ? 'https' : 'http'); 106 | 107 | $request = new Request($uri, 'GET'); 108 | $request->setHeaders($handshake->getHeaders()); 109 | 110 | $request->setTcpConnectTimeout($handshake->getTcpConnectTimeout()); 111 | $request->setTlsHandshakeTimeout($handshake->getTlsHandshakeTimeout()); 112 | $request->setHeaderSizeLimit($handshake->getHeaderSizeLimit()); 113 | 114 | $extensions = Http\splitHeader($request, 'sec-websocket-extensions') ?? []; 115 | 116 | if ($this->compressionContextFactory && \extension_loaded('zlib')) { 117 | $extensions[] = $this->compressionContextFactory->createRequestHeader(); 118 | } 119 | 120 | if ($extensions) { 121 | $request->setHeader('sec-websocket-extensions', \implode(', ', $extensions)); 122 | } 123 | 124 | $request->setProtocolVersions(['1.1']); 125 | $request->setHeader('connection', 'Upgrade'); 126 | $request->setHeader('upgrade', 'websocket'); 127 | $request->setHeader('sec-websocket-version', '13'); 128 | $request->setHeader('sec-websocket-key', $key); 129 | 130 | return $request; 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /src/WebsocketConnectException.php: -------------------------------------------------------------------------------- 1 | response; 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /src/WebsocketConnection.php: -------------------------------------------------------------------------------- 1 | setHeaders($headers); 38 | } 39 | 40 | /** 41 | * @return self Cloned object 42 | */ 43 | public function withUri(PsrUri|string $uri): self 44 | { 45 | $clone = clone $this; 46 | $clone->setUri(self::makeUri($uri)); 47 | 48 | return $clone; 49 | } 50 | 51 | /** 52 | * @return float Timeout in seconds for the TCP connection. 53 | */ 54 | public function getTcpConnectTimeout(): float 55 | { 56 | return $this->tcpConnectTimeout; 57 | } 58 | 59 | public function withTcpConnectTimeout(float $tcpConnectTimeout): self 60 | { 61 | $clone = clone $this; 62 | $clone->tcpConnectTimeout = $tcpConnectTimeout; 63 | 64 | return $clone; 65 | } 66 | 67 | /** 68 | * @return float Timeout in seconds for the TLS handshake. 69 | */ 70 | public function getTlsHandshakeTimeout(): float 71 | { 72 | return $this->tlsHandshakeTimeout; 73 | } 74 | 75 | public function withTlsHandshakeTimeout(float $tlsHandshakeTimeout): self 76 | { 77 | $clone = clone $this; 78 | $clone->tlsHandshakeTimeout = $tlsHandshakeTimeout; 79 | 80 | return $clone; 81 | } 82 | 83 | public function getHeaderSizeLimit(): int 84 | { 85 | return $this->headerSizeLimit; 86 | } 87 | 88 | public function withHeaderSizeLimit(int $headerSizeLimit): self 89 | { 90 | $clone = clone $this; 91 | $clone->headerSizeLimit = $headerSizeLimit; 92 | 93 | return $clone; 94 | } 95 | 96 | /** 97 | * Replaces all headers in the returned instance. 98 | * 99 | * @param HeaderParamArrayType $headers 100 | * 101 | * @return self Cloned object. 102 | */ 103 | public function withHeaders(array $headers): self 104 | { 105 | $clone = clone $this; 106 | $clone->setHeaders($headers); 107 | 108 | return $clone; 109 | } 110 | 111 | /** 112 | * Replaces the given header in the returned instance. 113 | * 114 | * @param non-empty-string $name 115 | * @param HeaderParamValueType $value 116 | * 117 | * @return self Cloned object. 118 | */ 119 | public function withHeader(string $name, string|array $value): self 120 | { 121 | $clone = clone $this; 122 | $clone->setHeader($name, $value); 123 | 124 | return $clone; 125 | } 126 | 127 | /** 128 | * Adds the given header in the returned instance. 129 | * 130 | * @param non-empty-string $name 131 | * @param HeaderParamValueType $value 132 | * 133 | * @return self Cloned object. 134 | */ 135 | public function withAddedHeader(string $name, string|array $value): self 136 | { 137 | $clone = clone $this; 138 | $clone->addHeader($name, $value); 139 | 140 | return $clone; 141 | } 142 | 143 | /** 144 | * Removes the given header in the returned instance. 145 | * 146 | * @return self Cloned object. 147 | */ 148 | public function withoutHeader(string $name): self 149 | { 150 | $clone = clone $this; 151 | $clone->removeHeader($name); 152 | 153 | return $clone; 154 | } 155 | 156 | protected function setHeader(string $name, array|string $value): void 157 | { 158 | if (($name[0] ?? ':') === ':') { 159 | throw new \Error("Header name cannot be empty or start with a colon (:)"); 160 | } 161 | 162 | parent::setHeader($name, $value); 163 | } 164 | 165 | protected function addHeader(string $name, array|string $value): void 166 | { 167 | if (($name[0] ?? ':') === ':') { 168 | throw new \Error("Header name cannot be empty or start with a colon (:)"); 169 | } 170 | 171 | parent::addHeader($name, $value); 172 | } 173 | 174 | /** 175 | * @param QueryArrayType $parameters 176 | * 177 | * @return self Cloned object. 178 | */ 179 | public function withQueryParameters(array $parameters): self 180 | { 181 | $clone = clone $this; 182 | $clone->setQueryParameters($parameters); 183 | 184 | return $clone; 185 | } 186 | 187 | /** 188 | * @param QueryValueType $value 189 | * 190 | * @return self Cloned object. 191 | */ 192 | public function withQueryParameter(string $key, array|string|null $value): self 193 | { 194 | $clone = clone $this; 195 | $clone->setQueryParameter($key, $value); 196 | 197 | return $clone; 198 | } 199 | 200 | /** 201 | * @param QueryValueType $value 202 | * 203 | * @return self Cloned object. 204 | */ 205 | public function withAddedQueryParameter(string $key, array|string|null $value): self 206 | { 207 | $clone = clone $this; 208 | $clone->addQueryParameter($key, $value); 209 | 210 | return $clone; 211 | } 212 | 213 | /** 214 | * @return self Cloned object. 215 | */ 216 | public function withoutQueryParameter(string $key): self 217 | { 218 | $clone = clone $this; 219 | $clone->removeQueryParameter($key); 220 | 221 | return $clone; 222 | } 223 | 224 | /** 225 | * @return self Cloned object. 226 | */ 227 | public function withoutQuery(): self 228 | { 229 | $clone = clone $this; 230 | $clone->removeQuery(); 231 | 232 | return $clone; 233 | } 234 | 235 | private static function makeUri(PsrUri|string $uri): PsrUri 236 | { 237 | if (\is_string($uri)) { 238 | try { 239 | /** @psalm-suppress DeprecatedMethod Using deprecated method to support 6.x and 7.x of league/uri */ 240 | $uri = Uri\Http::createFromString($uri); 241 | } catch (\Exception $exception) { 242 | throw new \ValueError('Invalid Websocket URI provided', 0, $exception); 243 | } 244 | } 245 | 246 | return match ($uri->getScheme()) { 247 | 'ws', 'wss' => $uri, 248 | default => throw new \ValueError('The URI scheme must be ws or wss, got "' . $uri->getScheme() . '"'), 249 | }; 250 | } 251 | } 252 | -------------------------------------------------------------------------------- /src/functions.php: -------------------------------------------------------------------------------- 1 | connect($handshake, $cancellation); 39 | } 40 | -------------------------------------------------------------------------------- /test-autobahn/config/fuzzingserver.json: -------------------------------------------------------------------------------- 1 | { 2 | "url": "ws://127.0.0.1:9001", 3 | "outdir": "./reports/clients", 4 | "cases": [ 5 | "*" 6 | ], 7 | "exclude-cases": [ 8 | "12.2.*", 9 | "12.3.*", 10 | "12.4.*", 11 | "12.5.*", 12 | "13.2.*", 13 | "13.3.*", 14 | "13.4.*", 15 | "13.5.*", 16 | "13.6.*", 17 | "13.7.*" 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /test-autobahn/runner.php: -------------------------------------------------------------------------------- 1 | connect(new WebsocketHandshake('ws://127.0.0.1:9001/getCaseCount')); 21 | $message = $connection->receive(); 22 | $cases = (int) $message->buffer(); 23 | 24 | echo "Going to run {$cases} test cases." . PHP_EOL; 25 | 26 | for ($i = 1; $i < $cases; $i++) { 27 | $handshake = new WebsocketHandshake('ws://127.0.0.1:9001/getCaseInfo?case=' . $i . '&agent=' . AGENT); 28 | $connection = $connector->connect($handshake); 29 | $message = $connection->receive(); 30 | $info = \json_decode($message->buffer(), true); 31 | 32 | print $info['id'] . ' ' . \str_repeat('-', 80 - \strlen($info['id']) - 1) . PHP_EOL; 33 | print \wordwrap($info['description'], 80, PHP_EOL) . ' '; 34 | 35 | $handshake = new WebsocketHandshake('ws://127.0.0.1:9001/runCase?case=' . $i . '&agent=' . AGENT); 36 | $connection = $connector->connect($handshake); 37 | 38 | try { 39 | while ($message = $connection->receive()) { 40 | $content = $message->buffer(); 41 | 42 | if ($message->isBinary()) { 43 | $connection->sendBinary($content); 44 | } else { 45 | $connection->sendText($content); 46 | } 47 | } 48 | } catch (WebsocketClosedException $e) { 49 | // ignore 50 | } catch (AssertionError $e) { 51 | print 'Assertion error: ' . $e->getMessage() . PHP_EOL; 52 | $connection->close(); 53 | } catch (Error $e) { 54 | print 'Error: ' . $e->getMessage() . PHP_EOL; 55 | $connection->close(); 56 | } catch (StreamException $e) { 57 | print 'Stream exception: ' . $e->getMessage() . PHP_EOL; 58 | $connection->close(); 59 | } 60 | 61 | $handshake = new WebsocketHandshake('ws://127.0.0.1:9001/getCaseStatus?case=' . $i . '&agent=' . AGENT); 62 | $connection = $connector->connect($handshake); 63 | $message = $connection->receive(); 64 | print($result = \json_decode($message->buffer(), true)['behavior']); 65 | 66 | if ($result === 'FAILED') { 67 | $errors++; 68 | } 69 | 70 | print PHP_EOL . PHP_EOL; 71 | } 72 | 73 | $connection = $connector->connect(new WebsocketHandshake('ws://127.0.0.1:9001/updateReports?agent=' . AGENT)); 74 | $connection->close(); 75 | 76 | if ($errors) { 77 | exit(1); 78 | } 79 | --------------------------------------------------------------------------------