├── .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 |
--------------------------------------------------------------------------------