├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ ├── feature_request.md
│ └── other-issue.md
└── workflows
│ └── acceptance.yml
├── .gitignore
├── COPYING.md
├── Makefile
├── README.md
├── composer.json
├── docs
├── Changelog.md
├── Client.md
├── Contributing.md
├── Examples.md
├── Message.md
└── Server.md
├── examples
├── echoserver.php
├── random_client.php
├── random_server.php
└── send.php
├── lib
├── BadOpcodeException.php
├── BadUriException.php
├── Client.php
├── Connection.php
├── ConnectionException.php
├── Exception.php
├── Message
│ ├── Binary.php
│ ├── Close.php
│ ├── Factory.php
│ ├── Message.php
│ ├── Ping.php
│ ├── Pong.php
│ └── Text.php
├── OpcodeTrait.php
├── Server.php
└── TimeoutException.php
├── phpunit.xml.dist
└── tests
├── ClientTest.php
├── ExceptionTest.php
├── MessageTest.php
├── README.md
├── ServerTest.php
├── bootstrap.php
├── mock
├── EchoLog.php
├── MockSocket.php
├── mock-socket.php
├── payload.128.txt
└── payload.65536.txt
└── scripts
├── client.close.json
├── client.connect-authed.json
├── client.connect-bad-context.json
├── client.connect-bad-stream.json
├── client.connect-context.json
├── client.connect-default-port-ws.json
├── client.connect-default-port-wss.json
├── client.connect-error.json
├── client.connect-extended.json
├── client.connect-failed.json
├── client.connect-handshake-error.json
├── client.connect-handshake-failure.json
├── client.connect-headers.json
├── client.connect-invalid-key.json
├── client.connect-invalid-upgrade.json
├── client.connect-persistent-failure.json
├── client.connect-persistent.json
├── client.connect-root.json
├── client.connect-timeout.json
├── client.connect.json
├── client.destruct.json
├── client.reconnect.json
├── close-remote.json
├── config-timeout.json
├── ping-pong.json
├── receive-bad-opcode.json
├── receive-broken-read.json
├── receive-client-timeout.json
├── receive-empty-read.json
├── receive-fragmentation.json
├── send-bad-opcode.json
├── send-broken-write.json
├── send-convenicance.json
├── send-failed-write.json
├── send-receive-128.json
├── send-receive-65536.json
├── send-receive-multi-fragment.json
├── send-receive.json
├── server.accept-destruct.json
├── server.accept-error-connect.json
├── server.accept-failed-connect.json
├── server.accept-failed-handshake.json
├── server.accept-failed-http.json
├── server.accept-failed-ws-key.json
├── server.accept-timeout.json
├── server.accept.json
├── server.close.json
├── server.construct-error-socket-server.json
├── server.construct-failed-socket-server.json
├── server.construct.json
└── server.disconnect.json
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Use this if you believe there is a bug in this repo
4 | title: ''
5 | labels: bug
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | Please provide a clear and concise description of the suspected issue.
12 |
13 | **How to reproduce**
14 | If possible, provide information - possibly including code snippets - on how to reproduce the issue.
15 |
16 | **Logs**
17 | If possible, provide logs that indicate the issue. See https://github.com/Textalk/websocket-php/blob/master/docs/Examples.md#echo-logger on how to use the EchoLog.
18 |
19 | **Versions**
20 | * Version of this library
21 | * PHP version
22 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this library
4 | title: ''
5 | labels: feature request
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is it within the scope of this library?**
11 | Consider and describe why the feature would be beneficial in this library, and not implemented as a separate project using this as a dependency.
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/other-issue.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Other issue
3 | about: Use this for other issues
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe your issue**
11 |
--------------------------------------------------------------------------------
/.github/workflows/acceptance.yml:
--------------------------------------------------------------------------------
1 | name: Acceptance
2 |
3 | on: [push, pull_request]
4 |
5 | jobs:
6 | test-7-4:
7 | runs-on: ubuntu-latest
8 | name: Test PHP 7.4
9 | steps:
10 | - name: Checkout
11 | uses: actions/checkout@v3
12 | - name: Set up PHP 7.4
13 | uses: shivammathur/setup-php@v2
14 | with:
15 | php-version: '7.4'
16 | - name: Composer
17 | run: make install
18 | - name: Test
19 | run: make test
20 |
21 | test-8-0:
22 | runs-on: ubuntu-latest
23 | name: Test PHP 8.0
24 | steps:
25 | - name: Checkout
26 | uses: actions/checkout@v3
27 | - name: Set up PHP 8.0
28 | uses: shivammathur/setup-php@v2
29 | with:
30 | php-version: '8.0'
31 | - name: Composer
32 | run: make install
33 | - name: Test
34 | run: make test
35 |
36 | test-8-1:
37 | runs-on: ubuntu-latest
38 | name: Test PHP 8.1
39 | steps:
40 | - name: Checkout
41 | uses: actions/checkout@v3
42 | - name: Set up PHP 8.1
43 | uses: shivammathur/setup-php@v2
44 | with:
45 | php-version: '8.1'
46 | - name: Composer
47 | run: make install
48 | - name: Test
49 | run: make test
50 |
51 | test-8-2:
52 | runs-on: ubuntu-latest
53 | name: Test PHP 8.2
54 | steps:
55 | - name: Checkout
56 | uses: actions/checkout@v3
57 | - name: Set up PHP 8.2
58 | uses: shivammathur/setup-php@v2
59 | with:
60 | php-version: '8.2'
61 | - name: Composer
62 | run: make install
63 | - name: Test
64 | run: make test
65 |
66 | cs-check:
67 | runs-on: ubuntu-latest
68 | name: Code standard
69 | steps:
70 | - name: Checkout
71 | uses: actions/checkout@v3
72 | - name: Set up PHP 8.0
73 | uses: shivammathur/setup-php@v2
74 | with:
75 | php-version: '8.0'
76 | - name: Composer
77 | run: make install
78 | - name: Code standard
79 | run: make cs-check
80 |
81 | coverage:
82 | runs-on: ubuntu-latest
83 | name: Code coverage
84 | steps:
85 | - name: Checkout
86 | uses: actions/checkout@v3
87 | - name: Set up PHP 8.0
88 | uses: shivammathur/setup-php@v2
89 | with:
90 | php-version: '8.0'
91 | extensions: xdebug
92 | - name: Composer
93 | run: make install
94 | - name: Code coverage
95 | env:
96 | COVERALLS_REPO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
97 | run: make coverage
98 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | .phpunit.result.cache
3 | build/
4 | composer.lock
5 | composer.phar
6 | vendor/
--------------------------------------------------------------------------------
/COPYING.md:
--------------------------------------------------------------------------------
1 | # Websocket: License
2 |
3 | Websocket PHP is free software released under the following license:
4 |
5 | [ISC License](http://en.wikipedia.org/wiki/ISC_license)
6 |
7 | Permission to use, copy, modify, and/or distribute this software for any purpose with or without
8 | fee is hereby granted, provided that the above copyright notice and this permission notice appear
9 | in all copies.
10 |
11 | THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH REGARD TO THIS
12 | SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE
13 | AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
14 | WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT,
15 | NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF
16 | THIS SOFTWARE.
17 |
--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
1 | install: composer.phar
2 | ./composer.phar install
3 |
4 | update: composer.phar
5 | ./composer.phar self-update
6 | ./composer.phar update
7 |
8 | test: composer.lock
9 | ./vendor/bin/phpunit
10 |
11 | cs-check: composer.lock
12 | ./vendor/bin/phpcs --standard=PSR1,PSR12 --encoding=UTF-8 --report=full --colors lib tests examples
13 |
14 | coverage: composer.lock build
15 | XDEBUG_MODE=coverage ./vendor/bin/phpunit --coverage-clover build/logs/clover.xml
16 | ./vendor/bin/php-coveralls -v
17 |
18 | composer.phar:
19 | curl -s http://getcomposer.org/installer | php
20 |
21 | composer.lock: composer.phar
22 | ./composer.phar --no-interaction install
23 |
24 | vendor/bin/phpunit: install
25 |
26 | build:
27 | mkdir build
28 |
29 | clean:
30 | rm composer.phar
31 | rm -r vendor
32 | rm -r build
33 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Websocket Client and Server for PHP
2 |
3 | [](https://github.com/Textalk/websocket-php/actions)
4 | [](https://coveralls.io/github/Textalk/websocket-php)
5 |
6 | ## Archived project
7 |
8 | This project has been archived and is no longer maintained. No bug fix and no additional features will be added.
9 | You won't be able to submit new issues or pull requests, and no additional features will be added
10 |
11 | This library has been replaced by [sirn-se/websocket-php](https://github.com/sirn-se/websocket-php)
12 |
13 | ## Websocket Client and Server for PHP
14 |
15 | This library contains WebSocket client and server for PHP.
16 |
17 | The client and server provides methods for reading and writing to WebSocket streams.
18 | It does not include convenience operations such as listeners and implicit error handling.
19 |
20 | ## Documentation
21 |
22 | - [Client](docs/Client.md)
23 | - [Server](docs/Server.md)
24 | - [Examples](docs/Examples.md)
25 | - [Changelog](docs/Changelog.md)
26 | - [Contributing](docs/Contributing.md)
27 |
28 | ## Installing
29 |
30 | Preferred way to install is with [Composer](https://getcomposer.org/).
31 | ```
32 | composer require textalk/websocket
33 | ```
34 |
35 | * Current version support PHP versions `^7.4|^8.0`.
36 | * For PHP `7.2` and `7.3` support use version [`1.5`](https://github.com/Textalk/websocket-php/tree/1.5.0).
37 | * For PHP `7.1` support use version [`1.4`](https://github.com/Textalk/websocket-php/tree/1.4.0).
38 | * For PHP `^5.4` and `7.0` support use version [`1.3`](https://github.com/Textalk/websocket-php/tree/1.3.0).
39 |
40 | ## Client
41 |
42 | The [client](docs/Client.md) can read and write on a WebSocket stream.
43 | It internally supports Upgrade handshake and implicit close and ping/pong operations.
44 |
45 | ```php
46 | $client = new WebSocket\Client("ws://echo.websocket.org/");
47 | $client->text("Hello WebSocket.org!");
48 | echo $client->receive();
49 | $client->close();
50 | ```
51 |
52 | ## Server
53 |
54 | The library contains a rudimentary single stream/single thread [server](docs/Server.md).
55 | It internally supports Upgrade handshake and implicit close and ping/pong operations.
56 |
57 | Note that it does **not** support threading or automatic association ot continuous client requests.
58 | If you require this kind of server behavior, you need to build it on top of provided server implementation.
59 |
60 | ```php
61 | $server = new WebSocket\Server();
62 | $server->accept();
63 | $message = $server->receive();
64 | $server->text($message);
65 | $server->close();
66 | ```
67 |
68 | ### License and Contributors
69 |
70 | [ISC License](COPYING.md)
71 |
72 | Fredrik Liljegren, Armen Baghumian Sankbarani, Ruslan Bekenev,
73 | Joshua Thijssen, Simon Lipp, Quentin Bellus, Patrick McCarren, swmcdonnell,
74 | Ignas Bernotas, Mark Herhold, Andreas Palm, Sören Jensen, pmaasz, Alexey Stavrov,
75 | Michael Slezak, Pierre Seznec, rmeisler, Nickolay V. Shmyrev, Christoph Kempen,
76 | Marc Roberts, Antonio Mora, Simon Podlipsky, etrinh.
77 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "textalk/websocket",
3 | "description": "WebSocket client and server",
4 | "license": "ISC",
5 | "type": "library",
6 | "authors": [
7 | {
8 | "name": "Fredrik Liljegren"
9 | },
10 | {
11 | "name": "Sören Jensen"
12 | }
13 | ],
14 | "autoload": {
15 | "psr-4": {
16 | "WebSocket\\": "lib"
17 | }
18 | },
19 | "autoload-dev": {
20 | "psr-4": {
21 | "WebSocket\\": "tests/mock"
22 | }
23 | },
24 | "require": {
25 | "php": "^7.4 | ^8.0",
26 | "phrity/net-uri": "^1.0",
27 | "phrity/util-errorhandler": "^1.0",
28 | "psr/log": "^1.0 | ^2.0 | ^3.0",
29 | "psr/http-message": "^1.0"
30 | },
31 | "require-dev": {
32 | "phpunit/phpunit": "^9.0",
33 | "php-coveralls/php-coveralls": "^2.0",
34 | "squizlabs/php_codesniffer": "^3.5"
35 | }
36 | }
37 |
--------------------------------------------------------------------------------
/docs/Changelog.md:
--------------------------------------------------------------------------------
1 | [Client](Client.md) • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • Changelog • [Contributing](Contributing.md)
2 |
3 | # Websocket: Changelog
4 |
5 | ## `v1.6`
6 |
7 | > PHP version `^7.4|^8.0`
8 |
9 | ### `1.6.3`
10 |
11 | * Fix issue with implicit default ports (@etrinh, @sirn-se)
12 |
13 | ### `1.6.2`
14 |
15 | * Fix issue where port was missing in socket uri (@sirn-se)
16 |
17 | ### `1.6.1`
18 |
19 | * Fix client path for http request (@simPod, @sirn-se)
20 |
21 | ### `1.6.0`
22 | * Connection separate from Client and Server (@sirn-se)
23 | * getPier() deprecated, replaced by getRemoteName() (@sirn-se)
24 | * Client accepts `Psr\Http\Message\UriInterface` as input for URI:s (@sirn-se)
25 | * Bad URI throws exception when Client is instanciated, previously when used (@sirn-se)
26 | * Preparations for multiple conection and listeners (@sirn-se)
27 | * Major internal refactoring (@sirn-se)
28 |
29 | ## `v1.5`
30 |
31 | > PHP version `^7.2|^8.0`
32 |
33 | ### `1.5.8`
34 |
35 | * Handle read error during handshake (@sirn-se)
36 |
37 | ### `1.5.7`
38 |
39 | * Large header block fix (@sirn-se)
40 |
41 | ### `1.5.6`
42 |
43 | * Add test for PHP 8.1 (@sirn-se)
44 | * Code standard (@sirn-se)
45 |
46 | ### `1.5.5`
47 |
48 | * Support for psr/log v2 and v3 (@simPod)
49 | * GitHub Actions replaces Travis (@sirn-se)
50 |
51 | ### `1.5.4`
52 |
53 | * Keep open connection on read timeout (@marcroberts)
54 |
55 | ### `1.5.3`
56 |
57 | * Fix for persistent connection (@sirn-se)
58 |
59 | ### `1.5.2`
60 |
61 | * Fix for getName() method (@sirn-se)
62 |
63 | ### `1.5.1`
64 |
65 | * Fix for persistent connections (@rmeisler)
66 |
67 | ### `1.5.0`
68 |
69 | * Convenience send methods; text(), binary(), ping(), pong() (@sirn-se)
70 | * Optional Message instance as receive() method return (@sirn-se)
71 | * Opcode filter for receive() method (@sirn-se)
72 | * Added PHP `8.0` support (@webpatser)
73 | * Dropped PHP `7.1` support (@sirn-se)
74 | * Fix for unordered fragmented messages (@sirn-se)
75 | * Improved error handling on stream calls (@sirn-se)
76 | * Various code re-write (@sirn-se)
77 |
78 | ## `v1.4`
79 |
80 | > PHP version `^7.1`
81 |
82 | #### `1.4.3`
83 |
84 | * Solve stream closure/get meta conflict (@sirn-se)
85 | * Examples and documentation overhaul (@sirn-se)
86 |
87 | #### `1.4.2`
88 |
89 | * Force stream close on read error (@sirn-se)
90 | * Authorization headers line feed (@sirn-se)
91 | * Documentation (@matias-pool, @sirn-se)
92 |
93 | #### `1.4.1`
94 |
95 | * Ping/Pong, handled internally to avoid breaking fragmented messages (@nshmyrev, @sirn-se)
96 | * Fix for persistent connections (@rmeisler)
97 | * Fix opcode bitmask (@peterjah)
98 |
99 | #### `1.4.0`
100 |
101 | * Dropped support of old PHP versions (@sirn-se)
102 | * Added PSR-3 Logging support (@sirn-se)
103 | * Persistent connection option (@slezakattack)
104 | * TimeoutException on connection time out (@slezakattack)
105 |
106 | ## `v1.3`
107 |
108 | > PHP version `^5.4` and `^7.0`
109 |
110 | #### `1.3.1`
111 |
112 | * Allow control messages without payload (@Logioniz)
113 | * Error code in ConnectionException (@sirn-se)
114 |
115 | #### `1.3.0`
116 |
117 | * Implements ping/pong frames (@pmccarren @Logioniz)
118 | * Close behaviour (@sirn-se)
119 | * Various fixes concerning connection handling (@sirn-se)
120 | * Overhaul of Composer, Travis and Coveralls setup, PSR code standard and unit tests (@sirn-se)
121 |
122 | ## `v1.2`
123 |
124 | > PHP version `^5.4` and `^7.0`
125 |
126 | #### `1.2.0`
127 |
128 | * Adding stream context options (to set e.g. SSL `allow_self_signed`).
129 |
130 | ## `v1.1`
131 |
132 | > PHP version `^5.4` and `^7.0`
133 |
134 | #### `1.1.2`
135 |
136 | * Fixed error message on broken frame.
137 |
138 | #### `1.1.1`
139 |
140 | * Adding license information.
141 |
142 | #### `1.1.0`
143 |
144 | * Supporting huge payloads.
145 |
146 | ## `v1.0`
147 |
148 | > PHP version `^5.4` and `^7.0`
149 |
150 | #### `1.0.3`
151 |
152 | * Bugfix: Correcting address in error-message
153 |
154 | #### `1.0.2`
155 |
156 | * Bugfix: Add port in request-header.
157 |
158 | #### `1.0.1`
159 |
160 | * Fixing a bug from empty payloads.
161 |
162 | #### `1.0.0`
163 |
164 | * Release as production ready.
165 | * Adding option to set/override headers.
166 | * Supporting basic authentication from user:pass in URL.
167 |
168 |
--------------------------------------------------------------------------------
/docs/Client.md:
--------------------------------------------------------------------------------
1 | Client • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md)
2 |
3 | # Websocket: Client
4 |
5 | The client can read and write on a WebSocket stream.
6 | It internally supports Upgrade handshake and implicit close and ping/pong operations.
7 |
8 | ## Class synopsis
9 |
10 | ```php
11 | WebSocket\Client {
12 |
13 | public __construct(UriInterface|string $uri, array $options = []);
14 | public __destruct();
15 | public __toString() : string;
16 |
17 | public text(string $payload) : void;
18 | public binary(string $payload) : void;
19 | public ping(string $payload = '') : void;
20 | public pong(string $payload = '') : void;
21 | public send(Message|string $payload, string $opcode = 'text', bool $masked = true) : void;
22 | public close(int $status = 1000, mixed $message = 'ttfn') : void;
23 | public receive() : Message|string|null;
24 |
25 | public getName() : string|null;
26 | public getRemoteName() : string|null;
27 | public getLastOpcode() : string;
28 | public getCloseStatus() : int;
29 | public isConnected() : bool;
30 | public setTimeout(int $seconds) : void;
31 | public setFragmentSize(int $fragment_size) : self;
32 | public getFragmentSize() : int;
33 | public setLogger(Psr\Log\LoggerInterface $logger = null) : void;
34 | }
35 | ```
36 |
37 | ## Examples
38 |
39 | ### Simple send-receive operation
40 |
41 | This example send a single message to a server, and output the response.
42 |
43 | ```php
44 | $client = new WebSocket\Client("ws://echo.websocket.org/");
45 | $client->text("Hello WebSocket.org!");
46 | echo $client->receive();
47 | $client->close();
48 | ```
49 |
50 | ### Listening to a server
51 |
52 | To continuously listen to incoming messages, you need to put the receive operation within a loop.
53 | Note that these functions **always** throw exception on any failure, including recoverable failures such as connection time out.
54 | By consuming exceptions, the code will re-connect the socket in next loop iteration.
55 |
56 | ```php
57 | $client = new WebSocket\Client("ws://echo.websocket.org/");
58 | while (true) {
59 | try {
60 | $message = $client->receive();
61 | // Act on received message
62 | // Break while loop to stop listening
63 | } catch (\WebSocket\ConnectionException $e) {
64 | // Possibly log errors
65 | }
66 | }
67 | $client->close();
68 | ```
69 |
70 | ### Filtering received messages
71 |
72 | By default the `receive()` method return messages of 'text' and 'binary' opcode.
73 | The filter option allows you to specify which message types to return.
74 |
75 | ```php
76 | $client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text']]);
77 | $client->receive(); // Only return 'text' messages
78 |
79 | $client = new WebSocket\Client("ws://echo.websocket.org/", ['filter' => ['text', 'binary', 'ping', 'pong', 'close']]);
80 | $client->receive(); // Return all messages
81 | ```
82 |
83 | ### Sending messages
84 |
85 | There are convenience methods to send messages with different opcodes.
86 | ```php
87 | $client = new WebSocket\Client("ws://echo.websocket.org/");
88 |
89 | // Convenience methods
90 | $client->text('A plain text message'); // Send an opcode=text message
91 | $client->binary($binary_string); // Send an opcode=binary message
92 | $client->ping(); // Send an opcode=ping frame
93 | $client->pong(); // Send an unsolicited opcode=pong frame
94 |
95 | // Generic send method
96 | $client->send($payload); // Sent as masked opcode=text
97 | $client->send($payload, 'binary'); // Sent as masked opcode=binary
98 | $client->send($payload, 'binary', false); // Sent as unmasked opcode=binary
99 | ```
100 |
101 | ## Constructor options
102 |
103 | The `$options` parameter in constructor accepts an associative array of options.
104 |
105 | * `context` - A stream context created using [stream_context_create](https://www.php.net/manual/en/function.stream-context-create).
106 | * `filter` - Array of opcodes to return on receive, default `['text', 'binary']`
107 | * `fragment_size` - Maximum payload size. Default 4096 chars.
108 | * `headers` - Additional headers as associative array name => content.
109 | * `logger` - A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger.
110 | * `persistent` - Connection is re-used between requests until time out is reached. Default false.
111 | * `return_obj` - Return a [Message](Message.md) instance on receive, default false
112 | * `timeout` - Time out in seconds. Default 5 seconds.
113 |
114 | ```php
115 | $context = stream_context_create();
116 | stream_context_set_option($context, 'ssl', 'verify_peer', false);
117 | stream_context_set_option($context, 'ssl', 'verify_peer_name', false);
118 |
119 | $client = new WebSocket\Client("ws://echo.websocket.org/", [
120 | 'context' => $context, // Attach stream context created above
121 | 'filter' => ['text', 'binary', 'ping'], // Specify message types for receive() to return
122 | 'headers' => [ // Additional headers, used to specify subprotocol
123 | 'Sec-WebSocket-Protocol' => 'soap',
124 | 'origin' => 'localhost',
125 | ],
126 | 'logger' => $my_psr3_logger, // Attach a PSR3 compatible logger
127 | 'return_obj' => true, // Return Message instance rather than just text
128 | 'timeout' => 60, // 1 minute time out
129 | ]);
130 | ```
131 |
132 | ## Exceptions
133 |
134 | * `WebSocket\BadOpcodeException` - Thrown if provided opcode is invalid.
135 | * `WebSocket\BadUriException` - Thrown if provided URI is invalid.
136 | * `WebSocket\ConnectionException` - Thrown on any socket I/O failure.
137 | * `WebSocket\TimeoutException` - Thrown when the socket experiences a time out.
138 |
--------------------------------------------------------------------------------
/docs/Contributing.md:
--------------------------------------------------------------------------------
1 | [Client](Client.md) • [Server](Server.md) • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • Contributing
2 |
3 | # Websocket: Contributing
4 |
5 | Everyone is welcome to help out!
6 | But to keep this project sustainable, please ensure your contribution respects the requirements below.
7 |
8 | ## PR Requirements
9 |
10 | Requirements on pull requests;
11 | * All tests **MUST** pass.
12 | * Code coverage **MUST** remain at 100%.
13 | * Code **MUST** adhere to PSR-1 and PSR-12 code standards.
14 |
15 | Base your patch on corresponding version branch, and target that version branch in your pull request.
16 |
17 | * `v1.6-master` current version
18 | * `v1.5-master` previous version, bug fixes only
19 | * Older versions should not be target of pull requests
20 |
21 |
22 | ## Dependency management
23 |
24 | Install or update dependencies using [Composer](https://getcomposer.org/).
25 |
26 | ```
27 | # Install dependencies
28 | make install
29 |
30 | # Update dependencies
31 | make update
32 | ```
33 |
34 | ## Code standard
35 |
36 | This project uses [PSR-1](https://www.php-fig.org/psr/psr-1/) and [PSR-12](https://www.php-fig.org/psr/psr-12/) code standards.
37 | ```
38 | # Check code standard adherence
39 | make cs-check
40 | ```
41 |
42 | ## Unit testing
43 |
44 | Unit tests with [PHPUnit](https://phpunit.readthedocs.io/), coverage with [Coveralls](https://github.com/php-coveralls/php-coveralls)
45 | ```
46 | # Run unit tests
47 | make test
48 |
49 | # Create coverage
50 | make coverage
51 | ```
52 |
--------------------------------------------------------------------------------
/docs/Examples.md:
--------------------------------------------------------------------------------
1 | [Client](Client.md) • [Server](Server.md) • [Message](Message.md) • Examples • [Changelog](Changelog.md) • [Contributing](Contributing.md)
2 |
3 | # Websocket: Examples
4 |
5 | Here are some examples on how to use the WebSocket library.
6 |
7 | ## Echo logger
8 |
9 | In dev environment (as in having run composer to include dev dependencies) you have
10 | access to a simple echo logger that print out information synchronously.
11 |
12 | This is usable for debugging. For production, use a proper logger.
13 |
14 | ```php
15 | namespace WebSocket;
16 |
17 | $logger = new EchoLogger();
18 |
19 | $client = new Client('ws://echo.websocket.org/');
20 | $client->setLogger($logger);
21 |
22 | $server = new Server();
23 | $server->setLogger($logger);
24 | ```
25 |
26 | An example of server output;
27 | ```
28 | info | Server listening to port 8000 []
29 | debug | Wrote 129 of 129 bytes. []
30 | info | Server connected to port 8000 []
31 | info | Received 'text' message []
32 | debug | Wrote 9 of 9 bytes. []
33 | info | Sent 'text' message []
34 | debug | Received 'close', status: 1000. []
35 | debug | Wrote 32 of 32 bytes. []
36 | info | Sent 'close' message []
37 | info | Received 'close' message []
38 | ```
39 |
40 | ## The `send` client
41 |
42 | Source: [examples/send.php](../examples/send.php)
43 |
44 | A simple, single send/receive client.
45 |
46 | Example use:
47 | ```
48 | php examples/send.php --opcode text "A text message" // Send a text message to localhost
49 | php examples/send.php --opcode ping "ping it" // Send a ping message to localhost
50 | php examples/send.php --uri ws://echo.websocket.org "A text message" // Send a text message to echo.websocket.org
51 | php examples/send.php --opcode text --debug "A text message" // Use runtime debugging
52 | ```
53 |
54 | ## The `echoserver` server
55 |
56 | Source: [examples/echoserver.php](../examples/echoserver.php)
57 |
58 | A simple server that responds to recevied commands.
59 |
60 | Example use:
61 | ```
62 | php examples/echoserver.php // Run with default settings
63 | php examples/echoserver.php --port 8080 // Listen on port 8080
64 | php examples/echoserver.php --debug // Use runtime debugging
65 | ```
66 |
67 | These strings can be sent as message to trigger server to perform actions;
68 | * `auth` - Server will respond with auth header if provided by client
69 | * `close` - Server will close current connection
70 | * `exit` - Server will close all active connections
71 | * `headers` - Server will respond with all headers provided by client
72 | * `ping` - Server will send a ping message
73 | * `pong` - Server will send a pong message
74 | * `stop` - Server will stop listening
75 | * For other sent strings, server will respond with the same strings
76 |
77 | ## The `random` client
78 |
79 | Source: [examples/random_client.php](../examples/random_client.php)
80 |
81 | The random client will use random options and continuously send/receive random messages.
82 |
83 | Example use:
84 | ```
85 | php examples/random_client.php --uri ws://echo.websocket.org // Connect to echo.websocket.org
86 | php examples/random_client.php --timeout 5 --fragment_size 16 // Specify settings
87 | php examples/random_client.php --debug // Use runtime debugging
88 | ```
89 |
90 | ## The `random` server
91 |
92 | Source: [examples/random_server.php](../examples/random_server.php)
93 |
94 | The random server will use random options and continuously send/receive random messages.
95 |
96 | Example use:
97 | ```
98 | php examples/random_server.php --port 8080 // // Listen on port 8080
99 | php examples/random_server.php --timeout 5 --fragment_size 16 // Specify settings
100 | php examples/random_server.php --debug // Use runtime debugging
101 | ```
102 |
--------------------------------------------------------------------------------
/docs/Message.md:
--------------------------------------------------------------------------------
1 | [Client](Client.md) • [Server](Server.md) • Message • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md)
2 |
3 | # Websocket: Messages
4 |
5 | If option `return_obj` is set to `true` on [client](Client.md) or [server](Server.md),
6 | the `receive()` method will return a Message instance instead of a string.
7 |
8 | Available classes correspond to opcode;
9 | * WebSocket\Message\Text
10 | * WebSocket\Message\Binary
11 | * WebSocket\Message\Ping
12 | * WebSocket\Message\Pong
13 | * WebSocket\Message\Close
14 |
15 | Additionally;
16 | * WebSocket\Message\Message - abstract base class for all messages above
17 | * WebSocket\Message\Factory - Factory class to create Message instances
18 |
19 | ## Message abstract class synopsis
20 |
21 | ```php
22 | WebSocket\Message\Message {
23 |
24 | public __construct(string $payload = '');
25 | public __toString() : string;
26 |
27 | public getOpcode() : string;
28 | public getLength() : int;
29 | public getTimestamp() : DateTime;
30 | public getContent() : string;
31 | public setContent(string $payload = '') : void;
32 | public hasContent() : bool;
33 | }
34 | ```
35 |
36 | ## Factory class synopsis
37 |
38 | ```php
39 | WebSocket\Message\Factory {
40 |
41 | public create(string $opcode, string $payload = '') : Message;
42 | }
43 | ```
44 |
45 | ## Example
46 |
47 | Receving a Message and echo some methods.
48 |
49 | ```php
50 | $client = new WebSocket\Client('ws://echo.websocket.org/', ['return_obj' => true]);
51 | $client->text('Hello WebSocket.org!');
52 | // Echo return same message as sent
53 | $message = $client->receive();
54 | echo $message->getOpcode(); // -> "text"
55 | echo $message->getLength(); // -> 20
56 | echo $message->getContent(); // -> "Hello WebSocket.org!"
57 | echo $message->hasContent(); // -> true
58 | echo $message->getTimestamp()->format('H:i:s'); // -> 19:37:18
59 | $client->close();
60 | ```
61 |
--------------------------------------------------------------------------------
/docs/Server.md:
--------------------------------------------------------------------------------
1 | [Client](Client.md) • Server • [Message](Message.md) • [Examples](Examples.md) • [Changelog](Changelog.md) • [Contributing](Contributing.md)
2 |
3 | # Websocket: Server
4 |
5 | The library contains a rudimentary single stream/single thread server.
6 | It internally supports Upgrade handshake and implicit close and ping/pong operations.
7 |
8 | Note that it does **not** support threading or automatic association ot continuous client requests.
9 | If you require this kind of server behavior, you need to build it on top of provided server implementation.
10 |
11 | ## Class synopsis
12 |
13 | ```php
14 | WebSocket\Server {
15 |
16 | public __construct(array $options = []);
17 | public __destruct();
18 | public __toString() : string;
19 |
20 | public accept() : bool;
21 | public text(string $payload) : void;
22 | public binary(string $payload) : void;
23 | public ping(string $payload = '') : void;
24 | public pong(string $payload = '') : void;
25 | public send(Message|string $payload, string $opcode = 'text', bool $masked = true) : void;
26 | public close(int $status = 1000, mixed $message = 'ttfn') : void;
27 | public receive() : Message|string|null;
28 |
29 | public getPort() : int;
30 | public getPath() : string;
31 | public getRequest() : array;
32 | public getHeader(string $header_name) : string|null;
33 |
34 | public getName() : string|null;
35 | public getRemoteName() : string|null;
36 | public getLastOpcode() : string;
37 | public getCloseStatus() : int;
38 | public isConnected() : bool;
39 | public setTimeout(int $seconds) : void;
40 | public setFragmentSize(int $fragment_size) : self;
41 | public getFragmentSize() : int;
42 | public setLogger(Psr\Log\LoggerInterface $logger = null) : void;
43 | }
44 | ```
45 |
46 | ## Examples
47 |
48 | ### Simple receive-send operation
49 |
50 | This example reads a single message from a client, and respond with the same message.
51 |
52 | ```php
53 | $server = new WebSocket\Server();
54 | $server->accept();
55 | $message = $server->receive();
56 | $server->text($message);
57 | $server->close();
58 | ```
59 |
60 | ### Listening to clients
61 |
62 | To continuously listen to incoming messages, you need to put the receive operation within a loop.
63 | Note that these functions **always** throw exception on any failure, including recoverable failures such as connection time out.
64 | By consuming exceptions, the code will re-connect the socket in next loop iteration.
65 |
66 | ```php
67 | $server = new WebSocket\Server();
68 | while ($server->accept()) {
69 | try {
70 | $message = $server->receive();
71 | // Act on received message
72 | // Break while loop to stop listening
73 | } catch (\WebSocket\ConnectionException $e) {
74 | // Possibly log errors
75 | }
76 | }
77 | $server->close();
78 | ```
79 |
80 | ### Filtering received messages
81 |
82 | By default the `receive()` method return messages of 'text' and 'binary' opcode.
83 | The filter option allows you to specify which message types to return.
84 |
85 | ```php
86 | $server = new WebSocket\Server(['filter' => ['text']]);
87 | $server->receive(); // only return 'text' messages
88 |
89 | $server = new WebSocket\Server(['filter' => ['text', 'binary', 'ping', 'pong', 'close']]);
90 | $server->receive(); // return all messages
91 | ```
92 |
93 | ### Sending messages
94 |
95 | There are convenience methods to send messages with different opcodes.
96 | ```php
97 | $server = new WebSocket\Server();
98 |
99 | // Convenience methods
100 | $server->text('A plain text message'); // Send an opcode=text message
101 | $server->binary($binary_string); // Send an opcode=binary message
102 | $server->ping(); // Send an opcode=ping frame
103 | $server->pong(); // Send an unsolicited opcode=pong frame
104 |
105 | // Generic send method
106 | $server->send($payload); // Sent as masked opcode=text
107 | $server->send($payload, 'binary'); // Sent as masked opcode=binary
108 | $server->send($payload, 'binary', false); // Sent as unmasked opcode=binary
109 | ```
110 |
111 | ## Constructor options
112 |
113 | The `$options` parameter in constructor accepts an associative array of options.
114 |
115 | * `filter` - Array of opcodes to return on receive, default `['text', 'binary']`
116 | * `fragment_size` - Maximum payload size. Default 4096 chars.
117 | * `logger` - A [PSR-3](https://www.php-fig.org/psr/psr-3/) compatible logger.
118 | * `port` - The server port to listen to. Default 8000.
119 | * `return_obj` - Return a [Message](Message.md) instance on receive, default false
120 | * `timeout` - Time out in seconds. Default 5 seconds.
121 |
122 | ```php
123 | $server = new WebSocket\Server([
124 | 'filter' => ['text', 'binary', 'ping'], // Specify message types for receive() to return
125 | 'logger' => $my_psr3_logger, // Attach a PSR3 compatible logger
126 | 'port' => 9000, // Listening port
127 | 'return_obj' => true, // Return Message instance rather than just text
128 | 'timeout' => 60, // 1 minute time out
129 | ]);
130 | ```
131 |
132 | ## Exceptions
133 |
134 | * `WebSocket\BadOpcodeException` - Thrown if provided opcode is invalid.
135 | * `WebSocket\ConnectionException` - Thrown on any socket I/O failure.
136 | * `WebSocket\TimeoutException` - Thrown when the socket experiences a time out.
137 |
--------------------------------------------------------------------------------
/examples/echoserver.php:
--------------------------------------------------------------------------------
1 | : The port to listen to, default 8000
9 | * --timeout : Timeout in seconds, default 200 seconds
10 | * --debug : Output log data (if logger is available)
11 | */
12 |
13 | namespace WebSocket;
14 |
15 | require __DIR__ . '/../vendor/autoload.php';
16 |
17 | error_reporting(-1);
18 |
19 | echo "> Echo server\n";
20 |
21 | // Server options specified or random
22 | $options = array_merge([
23 | 'port' => 8000,
24 | 'timeout' => 200,
25 | 'filter' => ['text', 'binary', 'ping', 'pong', 'close'],
26 | ], getopt('', ['port:', 'timeout:', 'debug']));
27 |
28 | // If debug mode and logger is available
29 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) {
30 | $logger = new EchoLog();
31 | $options['logger'] = $logger;
32 | echo "> Using logger\n";
33 | }
34 |
35 | // Initiate server.
36 | try {
37 | $server = new Server($options);
38 | } catch (ConnectionException $e) {
39 | echo "> ERROR: {$e->getMessage()}\n";
40 | die();
41 | }
42 |
43 | echo "> Listening to port {$server->getPort()}\n";
44 |
45 | // Force quit to close server
46 | while (true) {
47 | try {
48 | while ($server->accept()) {
49 | echo "> Accepted on port {$server->getPort()}\n";
50 | while (true) {
51 | $message = $server->receive();
52 | $opcode = $server->getLastOpcode();
53 | if (is_null($message)) {
54 | echo "> Closing connection\n";
55 | continue 2;
56 | }
57 | echo "> Got '{$message}' [opcode: {$opcode}]\n";
58 | if (in_array($opcode, ['ping', 'pong'])) {
59 | $server->send($message);
60 | continue;
61 | }
62 | // Allow certain string to trigger server action
63 | switch ($message) {
64 | case 'exit':
65 | echo "> Client told me to quit. Bye bye.\n";
66 | $server->close();
67 | echo "> Close status: {$server->getCloseStatus()}\n";
68 | exit;
69 | case 'headers':
70 | $server->text(implode("\r\n", $server->getRequest()));
71 | break;
72 | case 'ping':
73 | $server->ping($message);
74 | break;
75 | case 'auth':
76 | $auth = $server->getHeader('Authorization');
77 | $server->text("{$auth} - {$message}");
78 | break;
79 | default:
80 | $server->text($message);
81 | }
82 | }
83 | }
84 | } catch (ConnectionException $e) {
85 | echo "> ERROR: {$e->getMessage()}\n";
86 | }
87 | }
88 |
--------------------------------------------------------------------------------
/examples/random_client.php:
--------------------------------------------------------------------------------
1 | : The URI to connect to, default ws://localhost:8000
9 | * --timeout : Timeout in seconds, random default
10 | * --fragment_size : Fragment size as bytes, random default
11 | * --debug : Output log data (if logger is available)
12 | */
13 |
14 | namespace WebSocket;
15 |
16 | require __DIR__ . '/../vendor/autoload.php';
17 |
18 | error_reporting(-1);
19 |
20 | $randStr = function (int $maxlength = 4096) {
21 | $string = '';
22 | $length = rand(1, $maxlength);
23 | for ($i = 0; $i < $length; $i++) {
24 | $string .= chr(rand(33, 126));
25 | }
26 | return $string;
27 | };
28 |
29 | echo "> Random client\n";
30 |
31 | // Server options specified or random
32 | $options = array_merge([
33 | 'uri' => 'ws://localhost:8000',
34 | 'timeout' => rand(1, 60),
35 | 'fragment_size' => rand(1, 4096) * 8,
36 | ], getopt('', ['uri:', 'timeout:', 'fragment_size:', 'debug']));
37 |
38 | // If debug mode and logger is available
39 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) {
40 | $logger = new EchoLog();
41 | $options['logger'] = $logger;
42 | echo "> Using logger\n";
43 | }
44 |
45 | // Main loop
46 | while (true) {
47 | try {
48 | $client = new Client($options['uri'], $options);
49 | $info = json_encode([
50 | 'uri' => $options['uri'],
51 | 'timeout' => $options['timeout'],
52 | 'framgemt_size' => $client->getFragmentSize(),
53 | ]);
54 | echo "> Creating client {$info}\n";
55 |
56 | try {
57 | while (true) {
58 | // Random actions
59 | switch (rand(1, 10)) {
60 | case 1:
61 | echo "> Sending text\n";
62 | $client->text("Text message {$randStr()}");
63 | break;
64 | case 2:
65 | echo "> Sending binary\n";
66 | $client->binary("Binary message {$randStr()}");
67 | break;
68 | case 3:
69 | echo "> Sending close\n";
70 | $client->close(rand(1000, 2000), "Close message {$randStr(8)}");
71 | break;
72 | case 4:
73 | echo "> Sending ping\n";
74 | $client->ping("Ping message {$randStr(8)}");
75 | break;
76 | case 5:
77 | echo "> Sending pong\n";
78 | $client->pong("Pong message {$randStr(8)}");
79 | break;
80 | default:
81 | echo "> Receiving\n";
82 | $received = $client->receive();
83 | echo "> Received {$client->getLastOpcode()}: {$received}\n";
84 | }
85 | sleep(rand(1, 5));
86 | }
87 | } catch (\Throwable $e) {
88 | echo "ERROR I/O: {$e->getMessage()} [{$e->getCode()}]\n";
89 | }
90 | } catch (\Throwable $e) {
91 | echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n";
92 | }
93 | sleep(rand(1, 5));
94 | }
95 |
--------------------------------------------------------------------------------
/examples/random_server.php:
--------------------------------------------------------------------------------
1 | : The port to listen to, default 8000
9 | * --timeout : Timeout in seconds, random default
10 | * --fragment_size : Fragment size as bytes, random default
11 | * --debug : Output log data (if logger is available)
12 | */
13 |
14 | namespace WebSocket;
15 |
16 | require __DIR__ . '/../vendor/autoload.php';
17 |
18 | error_reporting(-1);
19 |
20 | $randStr = function (int $maxlength = 4096) {
21 | $string = '';
22 | $length = rand(1, $maxlength);
23 | for ($i = 0; $i < $length; $i++) {
24 | $string .= chr(rand(33, 126));
25 | }
26 | return $string;
27 | };
28 |
29 | echo "> Random server\n";
30 |
31 | // Server options specified or random
32 | $options = array_merge([
33 | 'port' => 8000,
34 | 'timeout' => rand(1, 60),
35 | 'fragment_size' => rand(1, 4096) * 8,
36 | ], getopt('', ['port:', 'timeout:', 'fragment_size:', 'debug']));
37 |
38 | // If debug mode and logger is available
39 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) {
40 | $logger = new EchoLog();
41 | $options['logger'] = $logger;
42 | echo "> Using logger\n";
43 | }
44 |
45 | // Force quit to close server
46 | while (true) {
47 | try {
48 | // Setup server
49 | $server = new Server($options);
50 | $info = json_encode([
51 | 'port' => $server->getPort(),
52 | 'timeout' => $options['timeout'],
53 | 'framgemt_size' => $server->getFragmentSize(),
54 | ]);
55 | echo "> Creating server {$info}\n";
56 |
57 | while ($server->accept()) {
58 | while (true) {
59 | // Random actions
60 | switch (rand(1, 10)) {
61 | case 1:
62 | echo "> Sending text\n";
63 | $server->text("Text message {$randStr()}");
64 | break;
65 | case 2:
66 | echo "> Sending binary\n";
67 | $server->binary("Binary message {$randStr()}");
68 | break;
69 | case 3:
70 | echo "> Sending close\n";
71 | $server->close(rand(1000, 2000), "Close message {$randStr(8)}");
72 | break;
73 | case 4:
74 | echo "> Sending ping\n";
75 | $server->ping("Ping message {$randStr(8)}");
76 | break;
77 | case 5:
78 | echo "> Sending pong\n";
79 | $server->pong("Pong message {$randStr(8)}");
80 | break;
81 | default:
82 | echo "> Receiving\n";
83 | $received = $server->receive();
84 | echo "> Received {$server->getLastOpcode()}: {$received}\n";
85 | }
86 | sleep(rand(1, 5));
87 | }
88 | }
89 | } catch (\Throwable $e) {
90 | echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n";
91 | }
92 | sleep(rand(1, 5));
93 | }
94 |
--------------------------------------------------------------------------------
/examples/send.php:
--------------------------------------------------------------------------------
1 |
6 | *
7 | * Console options:
8 | * --uri : The URI to connect to, default ws://localhost:8000
9 | * --opcode : Opcode to send, default 'text'
10 | * --debug : Output log data (if logger is available)
11 | */
12 |
13 | namespace WebSocket;
14 |
15 | require __DIR__ . '/../vendor/autoload.php';
16 |
17 | error_reporting(-1);
18 |
19 | echo "> Send client\n";
20 |
21 | // Server options specified or random
22 | $options = array_merge([
23 | 'uri' => 'ws://localhost:8000',
24 | 'opcode' => 'text',
25 | ], getopt('', ['uri:', 'opcode:', 'debug']));
26 | $message = array_pop($argv);
27 |
28 | // If debug mode and logger is available
29 | if (isset($options['debug']) && class_exists('WebSocket\EchoLog')) {
30 | $logger = new EchoLog();
31 | $options['logger'] = $logger;
32 | echo "> Using logger\n";
33 | }
34 |
35 | try {
36 | // Create client, send and recevie
37 | $client = new Client($options['uri'], $options);
38 | $client->send($message, $options['opcode']);
39 | echo "> Sent '{$message}' [opcode: {$options['opcode']}]\n";
40 | if (in_array($options['opcode'], ['text', 'binary'])) {
41 | $message = $client->receive();
42 | $opcode = $client->getLastOpcode();
43 | if (!is_null($message)) {
44 | echo "> Got '{$message}' [opcode: {$opcode}]\n";
45 | }
46 | }
47 | $client->close();
48 | echo "> Closing client\n";
49 | } catch (\Throwable $e) {
50 | echo "ERROR: {$e->getMessage()} [{$e->getCode()}]\n";
51 | }
52 |
--------------------------------------------------------------------------------
/lib/BadOpcodeException.php:
--------------------------------------------------------------------------------
1 | null,
33 | 'filter' => ['text', 'binary'],
34 | 'fragment_size' => 4096,
35 | 'headers' => null,
36 | 'logger' => null,
37 | 'origin' => null, // @deprecated
38 | 'persistent' => false,
39 | 'return_obj' => false,
40 | 'timeout' => 5,
41 | ];
42 |
43 | private $socket_uri;
44 | private $connection;
45 | private $options = [];
46 | private $listen = false;
47 | private $last_opcode = null;
48 |
49 |
50 | /* ---------- Magic methods ------------------------------------------------------ */
51 |
52 | /**
53 | * @param UriInterface|string $uri A ws/wss-URI
54 | * @param array $options
55 | * Associative array containing:
56 | * - context: Set the stream context. Default: empty context
57 | * - timeout: Set the socket timeout in seconds. Default: 5
58 | * - fragment_size: Set framgemnt size. Default: 4096
59 | * - headers: Associative array of headers to set/override.
60 | */
61 | public function __construct($uri, array $options = [])
62 | {
63 | $this->socket_uri = $this->parseUri($uri);
64 | $this->options = array_merge(self::$default_options, [
65 | 'logger' => new NullLogger(),
66 | ], $options);
67 | $this->setLogger($this->options['logger']);
68 | }
69 |
70 | /**
71 | * Get string representation of instance.
72 | * @return string String representation.
73 | */
74 | public function __toString(): string
75 | {
76 | return sprintf(
77 | "%s(%s)",
78 | get_class($this),
79 | $this->getName() ?: 'closed'
80 | );
81 | }
82 |
83 |
84 | /* ---------- Client option functions -------------------------------------------- */
85 |
86 | /**
87 | * Set timeout.
88 | * @param int $timeout Timeout in seconds.
89 | */
90 | public function setTimeout(int $timeout): void
91 | {
92 | $this->options['timeout'] = $timeout;
93 | if (!$this->isConnected()) {
94 | return;
95 | }
96 | $this->connection->setTimeout($timeout);
97 | $this->connection->setOptions($this->options);
98 | }
99 |
100 | /**
101 | * Set fragmentation size.
102 | * @param int $fragment_size Fragment size in bytes.
103 | * @return self.
104 | */
105 | public function setFragmentSize(int $fragment_size): self
106 | {
107 | $this->options['fragment_size'] = $fragment_size;
108 | $this->connection->setOptions($this->options);
109 | return $this;
110 | }
111 |
112 | /**
113 | * Get fragmentation size.
114 | * @return int $fragment_size Fragment size in bytes.
115 | */
116 | public function getFragmentSize(): int
117 | {
118 | return $this->options['fragment_size'];
119 | }
120 |
121 |
122 | /* ---------- Connection operations ---------------------------------------------- */
123 |
124 | /**
125 | * Send text message.
126 | * @param string $payload Content as string.
127 | */
128 | public function text(string $payload): void
129 | {
130 | $this->send($payload);
131 | }
132 |
133 | /**
134 | * Send binary message.
135 | * @param string $payload Content as binary string.
136 | */
137 | public function binary(string $payload): void
138 | {
139 | $this->send($payload, 'binary');
140 | }
141 |
142 | /**
143 | * Send ping.
144 | * @param string $payload Optional text as string.
145 | */
146 | public function ping(string $payload = ''): void
147 | {
148 | $this->send($payload, 'ping');
149 | }
150 |
151 | /**
152 | * Send unsolicited pong.
153 | * @param string $payload Optional text as string.
154 | */
155 | public function pong(string $payload = ''): void
156 | {
157 | $this->send($payload, 'pong');
158 | }
159 |
160 | /**
161 | * Send message.
162 | * @param string $payload Message to send.
163 | * @param string $opcode Opcode to use, default: 'text'.
164 | * @param bool $masked If message should be masked default: true.
165 | */
166 | public function send(string $payload, string $opcode = 'text', bool $masked = true): void
167 | {
168 | if (!$this->isConnected()) {
169 | $this->connect();
170 | }
171 |
172 | if (!in_array($opcode, array_keys(self::$opcodes))) {
173 | $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
174 | $this->logger->warning($warning);
175 | throw new BadOpcodeException($warning);
176 | }
177 |
178 | $factory = new Factory();
179 | $message = $factory->create($opcode, $payload);
180 | $this->connection->pushMessage($message, $masked);
181 | }
182 |
183 | /**
184 | * Tell the socket to close.
185 | * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
186 | * @param string $message A closing message, max 125 bytes.
187 | */
188 | public function close(int $status = 1000, string $message = 'ttfn'): void
189 | {
190 | if (!$this->isConnected()) {
191 | return;
192 | }
193 | $this->connection->close($status, $message);
194 | }
195 |
196 | /**
197 | * Disconnect from server.
198 | */
199 | public function disconnect(): void
200 | {
201 | if ($this->isConnected()) {
202 | $this->connection->disconnect();
203 | }
204 | }
205 |
206 | /**
207 | * Receive message.
208 | * Note that this operation will block reading.
209 | * @return mixed Message, text or null depending on settings.
210 | */
211 | public function receive()
212 | {
213 | $filter = $this->options['filter'];
214 | $return_obj = $this->options['return_obj'];
215 |
216 | if (!$this->isConnected()) {
217 | $this->connect();
218 | }
219 |
220 | while (true) {
221 | $message = $this->connection->pullMessage();
222 | $opcode = $message->getOpcode();
223 | if (in_array($opcode, $filter)) {
224 | $this->last_opcode = $opcode;
225 | $return = $return_obj ? $message : $message->getContent();
226 | break;
227 | } elseif ($opcode == 'close') {
228 | $this->last_opcode = null;
229 | $return = $return_obj ? $message : null;
230 | break;
231 | }
232 | }
233 | return $return;
234 | }
235 |
236 |
237 | /* ---------- Connection functions ----------------------------------------------- */
238 |
239 | /**
240 | * Get last received opcode.
241 | * @return string|null Opcode.
242 | */
243 | public function getLastOpcode(): ?string
244 | {
245 | return $this->last_opcode;
246 | }
247 |
248 | /**
249 | * Get close status on connection.
250 | * @return int|null Close status.
251 | */
252 | public function getCloseStatus(): ?int
253 | {
254 | return $this->connection ? $this->connection->getCloseStatus() : null;
255 | }
256 |
257 | /**
258 | * If Client has active connection.
259 | * @return bool True if active connection.
260 | */
261 | public function isConnected(): bool
262 | {
263 | return $this->connection && $this->connection->isConnected();
264 | }
265 |
266 | /**
267 | * Get name of local socket, or null if not connected.
268 | * @return string|null
269 | */
270 | public function getName(): ?string
271 | {
272 | return $this->isConnected() ? $this->connection->getName() : null;
273 | }
274 |
275 | /**
276 | * Get name of remote socket, or null if not connected.
277 | * @return string|null
278 | */
279 | public function getRemoteName(): ?string
280 | {
281 | return $this->isConnected() ? $this->connection->getRemoteName() : null;
282 | }
283 |
284 | /**
285 | * Get name of remote socket, or null if not connected.
286 | * @return string|null
287 | * @deprecated Will be removed in future version, use getPeer() instead.
288 | */
289 | public function getPier(): ?string
290 | {
291 | trigger_error(
292 | 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
293 | E_USER_DEPRECATED
294 | );
295 | return $this->getRemoteName();
296 | }
297 |
298 |
299 | /* ---------- Helper functions --------------------------------------------------- */
300 |
301 | /**
302 | * Perform WebSocket handshake
303 | */
304 | protected function connect(): void
305 | {
306 | $this->connection = null;
307 |
308 | $host_uri = $this->socket_uri
309 | ->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp')
310 | ->withPort($this->socket_uri->getPort() ?? ($this->socket_uri->getScheme() == 'wss' ? 443 : 80))
311 | ->withPath('')
312 | ->withQuery('')
313 | ->withFragment('')
314 | ->withUserInfo('');
315 |
316 | // Path must be absolute
317 | $http_path = $this->socket_uri->getPath();
318 | if ($http_path === '' || $http_path[0] !== '/') {
319 | $http_path = "/{$http_path}";
320 | }
321 |
322 | $http_uri = (new Uri())
323 | ->withPath($http_path)
324 | ->withQuery($this->socket_uri->getQuery());
325 |
326 | // Set the stream context options if they're already set in the config
327 | if (isset($this->options['context'])) {
328 | // Suppress the error since we'll catch it below
329 | if (@get_resource_type($this->options['context']) === 'stream-context') {
330 | $context = $this->options['context'];
331 | } else {
332 | $error = "Stream context in \$options['context'] isn't a valid context.";
333 | $this->logger->error($error);
334 | throw new \InvalidArgumentException($error);
335 | }
336 | } else {
337 | $context = stream_context_create();
338 | }
339 |
340 | $persistent = $this->options['persistent'] === true;
341 | $flags = STREAM_CLIENT_CONNECT;
342 | $flags = $persistent ? $flags | STREAM_CLIENT_PERSISTENT : $flags;
343 | $socket = null;
344 |
345 | try {
346 | $handler = new ErrorHandler();
347 | $socket = $handler->with(function () use ($host_uri, $flags, $context) {
348 | $error = $errno = $errstr = null;
349 | // Open the socket.
350 | return stream_socket_client(
351 | $host_uri,
352 | $errno,
353 | $errstr,
354 | $this->options['timeout'],
355 | $flags,
356 | $context
357 | );
358 | });
359 | if (!$socket) {
360 | throw new ErrorException('No socket');
361 | }
362 | } catch (ErrorException $e) {
363 | $error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$e->getMessage()} ({$e->getCode()}).";
364 | $this->logger->error($error, ['severity' => $e->getSeverity()]);
365 | throw new ConnectionException($error, 0, [], $e);
366 | }
367 |
368 | $this->connection = new Connection($socket, $this->options);
369 | $this->connection->setLogger($this->logger);
370 | if (!$this->isConnected()) {
371 | $error = "Invalid stream on \"{$host_uri->getAuthority()}\".";
372 | $this->logger->error($error);
373 | throw new ConnectionException($error);
374 | }
375 |
376 | if (!$persistent || $this->connection->tell() == 0) {
377 | // Set timeout on the stream as well.
378 | $this->connection->setTimeout($this->options['timeout']);
379 |
380 | // Generate the WebSocket key.
381 | $key = self::generateKey();
382 |
383 | // Default headers
384 | $headers = [
385 | 'Host' => $host_uri->getAuthority(),
386 | 'User-Agent' => 'websocket-client-php',
387 | 'Connection' => 'Upgrade',
388 | 'Upgrade' => 'websocket',
389 | 'Sec-WebSocket-Key' => $key,
390 | 'Sec-WebSocket-Version' => '13',
391 | ];
392 |
393 | // Handle basic authentication.
394 | if ($userinfo = $this->socket_uri->getUserInfo()) {
395 | $headers['authorization'] = 'Basic ' . base64_encode($userinfo);
396 | }
397 |
398 | // Deprecated way of adding origin (use headers instead).
399 | if (isset($this->options['origin'])) {
400 | $headers['origin'] = $this->options['origin'];
401 | }
402 |
403 | // Add and override with headers from options.
404 | if (isset($this->options['headers'])) {
405 | $headers = array_merge($headers, $this->options['headers']);
406 | }
407 |
408 | $header = "GET {$http_uri} HTTP/1.1\r\n" . implode(
409 | "\r\n",
410 | array_map(
411 | function ($key, $value) {
412 | return "$key: $value";
413 | },
414 | array_keys($headers),
415 | $headers
416 | )
417 | ) . "\r\n\r\n";
418 |
419 | // Send headers.
420 | $this->connection->write($header);
421 |
422 | // Get server response header (terminated with double CR+LF).
423 | $response = '';
424 | try {
425 | do {
426 | $buffer = $this->connection->gets(1024);
427 | $response .= $buffer;
428 | } while (substr_count($response, "\r\n\r\n") == 0);
429 | } catch (Exception $e) {
430 | throw new ConnectionException('Client handshake error', $e->getCode(), $e->getData(), $e);
431 | }
432 |
433 | // Validate response.
434 | if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) {
435 | $error = sprintf(
436 | "Connection to '%s' failed: Server sent invalid upgrade response: %s",
437 | (string)$this->socket_uri,
438 | (string)$response
439 | );
440 | $this->logger->error($error);
441 | throw new ConnectionException($error);
442 | }
443 |
444 | $keyAccept = trim($matches[1]);
445 | $expectedResonse = base64_encode(
446 | pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
447 | );
448 |
449 | if ($keyAccept !== $expectedResonse) {
450 | $error = 'Server sent bad upgrade response.';
451 | $this->logger->error($error);
452 | throw new ConnectionException($error);
453 | }
454 | }
455 |
456 | $this->logger->info("Client connected to {$this->socket_uri}");
457 | }
458 |
459 | /**
460 | * Generate a random string for WebSocket key.
461 | * @return string Random string
462 | */
463 | protected static function generateKey(): string
464 | {
465 | $key = '';
466 | for ($i = 0; $i < 16; $i++) {
467 | $key .= chr(rand(33, 126));
468 | }
469 | return base64_encode($key);
470 | }
471 |
472 | protected function parseUri($uri): UriInterface
473 | {
474 | if ($uri instanceof UriInterface) {
475 | $uri = $uri;
476 | } elseif (is_string($uri)) {
477 | try {
478 | $uri = new Uri($uri);
479 | } catch (InvalidArgumentException $e) {
480 | throw new BadUriException("Invalid URI '{$uri}' provided.", 0, $e);
481 | }
482 | } else {
483 | throw new BadUriException("Provided URI must be a UriInterface or string.");
484 | }
485 | if (!in_array($uri->getScheme(), ['ws', 'wss'])) {
486 | throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'.");
487 | }
488 | return $uri;
489 | }
490 | }
491 |
--------------------------------------------------------------------------------
/lib/Connection.php:
--------------------------------------------------------------------------------
1 | stream = $stream;
42 | $this->setOptions($options);
43 | $this->setLogger(new NullLogger());
44 | $this->msg_factory = new Factory();
45 | }
46 |
47 | public function __destruct()
48 | {
49 | if ($this->getType() === 'stream') {
50 | fclose($this->stream);
51 | }
52 | }
53 |
54 | public function setOptions(array $options = []): void
55 | {
56 | $this->options = array_merge($this->options, $options);
57 | }
58 |
59 | public function getCloseStatus(): ?int
60 | {
61 | return $this->close_status;
62 | }
63 |
64 | /**
65 | * Tell the socket to close.
66 | *
67 | * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
68 | * @param string $message A closing message, max 125 bytes.
69 | */
70 | public function close(int $status = 1000, string $message = 'ttfn'): void
71 | {
72 | if (!$this->isConnected()) {
73 | return;
74 | }
75 | $status_binstr = sprintf('%016b', $status);
76 | $status_str = '';
77 | foreach (str_split($status_binstr, 8) as $binstr) {
78 | $status_str .= chr(bindec($binstr));
79 | }
80 | $message = $this->msg_factory->create('close', $status_str . $message);
81 | $this->pushMessage($message, true);
82 |
83 | $this->logger->debug("Closing with status: {$status}.");
84 |
85 | $this->is_closing = true;
86 | while (true) {
87 | $message = $this->pullMessage();
88 | if ($message->getOpcode() == 'close') {
89 | break;
90 | }
91 | }
92 | }
93 |
94 |
95 | /* ---------- Message methods ---------------------------------------------------- */
96 |
97 | // Push a message to stream
98 | public function pushMessage(Message $message, bool $masked = true): void
99 | {
100 | $frames = $message->getFrames($masked, $this->options['fragment_size']);
101 | foreach ($frames as $frame) {
102 | $this->pushFrame($frame);
103 | }
104 | $this->logger->info("[connection] Pushed {$message}", [
105 | 'opcode' => $message->getOpcode(),
106 | 'content-length' => $message->getLength(),
107 | 'frames' => count($frames),
108 | ]);
109 | }
110 |
111 | // Pull a message from stream
112 | public function pullMessage(): Message
113 | {
114 | do {
115 | $frame = $this->pullFrame();
116 | $frame = $this->autoRespond($frame);
117 | list ($final, $payload, $opcode, $masked) = $frame;
118 |
119 | if ($opcode == 'close') {
120 | $this->close();
121 | }
122 |
123 | // Continuation and factual opcode
124 | $continuation = $opcode == 'continuation';
125 | $payload_opcode = $continuation ? $this->read_buffer['opcode'] : $opcode;
126 |
127 | // First continuation frame, create buffer
128 | if (!$final && !$continuation) {
129 | $this->read_buffer = ['opcode' => $opcode, 'payload' => $payload, 'frames' => 1];
130 | continue; // Continue reading
131 | }
132 |
133 | // Subsequent continuation frames, add to buffer
134 | if ($continuation) {
135 | $this->read_buffer['payload'] .= $payload;
136 | $this->read_buffer['frames']++;
137 | }
138 | } while (!$final);
139 |
140 | // Final, return payload
141 | $frames = 1;
142 | if ($continuation) {
143 | $payload = $this->read_buffer['payload'];
144 | $frames = $this->read_buffer['frames'];
145 | $this->read_buffer = null;
146 | }
147 |
148 | $message = $this->msg_factory->create($payload_opcode, $payload);
149 |
150 | $this->logger->info("[connection] Pulled {$message}", [
151 | 'opcode' => $payload_opcode,
152 | 'content-length' => strlen($payload),
153 | 'frames' => $frames,
154 | ]);
155 |
156 | return $message;
157 | }
158 |
159 |
160 | /* ---------- Frame I/O methods -------------------------------------------------- */
161 |
162 | // Pull frame from stream
163 | private function pullFrame(): array
164 | {
165 | // Read the fragment "header" first, two bytes.
166 | $data = $this->read(2);
167 | list ($byte_1, $byte_2) = array_values(unpack('C*', $data));
168 | $final = (bool)($byte_1 & 0b10000000); // Final fragment marker.
169 | $rsv = $byte_1 & 0b01110000; // Unused bits, ignore
170 |
171 | // Parse opcode
172 | $opcode_int = $byte_1 & 0b00001111;
173 | $opcode_ints = array_flip(self::$opcodes);
174 | if (!array_key_exists($opcode_int, $opcode_ints)) {
175 | $warning = "Bad opcode in websocket frame: {$opcode_int}";
176 | $this->logger->warning($warning);
177 | throw new ConnectionException($warning, ConnectionException::BAD_OPCODE);
178 | }
179 | $opcode = $opcode_ints[$opcode_int];
180 |
181 | // Masking bit
182 | $masked = (bool)($byte_2 & 0b10000000);
183 |
184 | $payload = '';
185 |
186 | // Payload length
187 | $payload_length = $byte_2 & 0b01111111;
188 |
189 | if ($payload_length > 125) {
190 | if ($payload_length === 126) {
191 | $data = $this->read(2); // 126: Payload is a 16-bit unsigned int
192 | $payload_length = current(unpack('n', $data));
193 | } else {
194 | $data = $this->read(8); // 127: Payload is a 64-bit unsigned int
195 | $payload_length = current(unpack('J', $data));
196 | }
197 | }
198 |
199 | // Get masking key.
200 | if ($masked) {
201 | $masking_key = $this->read(4);
202 | }
203 |
204 | // Get the actual payload, if any (might not be for e.g. close frames.
205 | if ($payload_length > 0) {
206 | $data = $this->read($payload_length);
207 |
208 | if ($masked) {
209 | // Unmask payload.
210 | for ($i = 0; $i < $payload_length; $i++) {
211 | $payload .= ($data[$i] ^ $masking_key[$i % 4]);
212 | }
213 | } else {
214 | $payload = $data;
215 | }
216 | }
217 |
218 | $this->logger->debug("[connection] Pulled '{opcode}' frame", [
219 | 'opcode' => $opcode,
220 | 'final' => $final,
221 | 'content-length' => strlen($payload),
222 | ]);
223 | return [$final, $payload, $opcode, $masked];
224 | }
225 |
226 | // Push frame to stream
227 | private function pushFrame(array $frame): void
228 | {
229 | list ($final, $payload, $opcode, $masked) = $frame;
230 | $data = '';
231 | $byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker.
232 | $byte_1 |= self::$opcodes[$opcode]; // Set opcode.
233 | $data .= pack('C', $byte_1);
234 |
235 | $byte_2 = $masked ? 0b10000000 : 0b00000000; // Masking bit marker.
236 |
237 | // 7 bits of payload length...
238 | $payload_length = strlen($payload);
239 | if ($payload_length > 65535) {
240 | $data .= pack('C', $byte_2 | 0b01111111);
241 | $data .= pack('J', $payload_length);
242 | } elseif ($payload_length > 125) {
243 | $data .= pack('C', $byte_2 | 0b01111110);
244 | $data .= pack('n', $payload_length);
245 | } else {
246 | $data .= pack('C', $byte_2 | $payload_length);
247 | }
248 |
249 | // Handle masking
250 | if ($masked) {
251 | // generate a random mask:
252 | $mask = '';
253 | for ($i = 0; $i < 4; $i++) {
254 | $mask .= chr(rand(0, 255));
255 | }
256 | $data .= $mask;
257 |
258 | // Append payload to frame:
259 | for ($i = 0; $i < $payload_length; $i++) {
260 | $data .= $payload[$i] ^ $mask[$i % 4];
261 | }
262 | } else {
263 | $data .= $payload;
264 | }
265 |
266 | $this->write($data);
267 |
268 | $this->logger->debug("[connection] Pushed '{$opcode}' frame", [
269 | 'opcode' => $opcode,
270 | 'final' => $final,
271 | 'content-length' => strlen($payload),
272 | ]);
273 | }
274 |
275 | // Trigger auto response for frame
276 | private function autoRespond(array $frame)
277 | {
278 | list ($final, $payload, $opcode, $masked) = $frame;
279 | $payload_length = strlen($payload);
280 |
281 | switch ($opcode) {
282 | case 'ping':
283 | // If we received a ping, respond with a pong
284 | $this->logger->debug("[connection] Received 'ping', sending 'pong'.");
285 | $message = $this->msg_factory->create('pong', $payload);
286 | $this->pushMessage($message, $masked);
287 | return [$final, $payload, $opcode, $masked];
288 | case 'close':
289 | // If we received close, possibly acknowledge and close connection
290 | $status_bin = '';
291 | $status = '';
292 | if ($payload_length > 0) {
293 | $status_bin = $payload[0] . $payload[1];
294 | $status = current(unpack('n', $payload));
295 | $this->close_status = $status;
296 | }
297 | // Get additional close message
298 | if ($payload_length >= 2) {
299 | $payload = substr($payload, 2);
300 | }
301 |
302 | $this->logger->debug("[connection] Received 'close', status: {$status}.");
303 | if (!$this->is_closing) {
304 | $ack = "{$status_bin}Close acknowledged: {$status}";
305 | $message = $this->msg_factory->create('close', $ack);
306 | $this->pushMessage($message, $masked);
307 | } else {
308 | $this->is_closing = false; // A close response, all done.
309 | }
310 | $this->disconnect();
311 | return [$final, $payload, $opcode, $masked];
312 | default:
313 | return [$final, $payload, $opcode, $masked];
314 | }
315 | }
316 |
317 |
318 | /* ---------- Stream I/O methods ------------------------------------------------- */
319 |
320 | /**
321 | * Close connection stream.
322 | * @return bool
323 | */
324 | public function disconnect(): bool
325 | {
326 | $this->logger->debug('Closing connection');
327 | return fclose($this->stream);
328 | }
329 |
330 | /**
331 | * If connected to stream.
332 | * @return bool
333 | */
334 | public function isConnected(): bool
335 | {
336 | return in_array($this->getType(), ['stream', 'persistent stream']);
337 | }
338 |
339 | /**
340 | * Return type of connection.
341 | * @return string|null Type of connection or null if invalid type.
342 | */
343 | public function getType(): ?string
344 | {
345 | return get_resource_type($this->stream);
346 | }
347 |
348 | /**
349 | * Get name of local socket, or null if not connected.
350 | * @return string|null
351 | */
352 | public function getName(): ?string
353 | {
354 | return stream_socket_get_name($this->stream, false);
355 | }
356 |
357 | /**
358 | * Get name of remote socket, or null if not connected.
359 | * @return string|null
360 | */
361 | public function getRemoteName(): ?string
362 | {
363 | return stream_socket_get_name($this->stream, true);
364 | }
365 |
366 | /**
367 | * Get meta data for connection.
368 | * @return array
369 | */
370 | public function getMeta(): array
371 | {
372 | return stream_get_meta_data($this->stream);
373 | }
374 |
375 | /**
376 | * Returns current position of stream pointer.
377 | * @return int
378 | * @throws ConnectionException
379 | */
380 | public function tell(): int
381 | {
382 | $tell = ftell($this->stream);
383 | if ($tell === false) {
384 | $this->throwException('Could not resolve stream pointer position');
385 | }
386 | return $tell;
387 | }
388 |
389 | /**
390 | * If stream pointer is at end of file.
391 | * @return bool
392 | */
393 | public function eof(): int
394 | {
395 | return feof($this->stream);
396 | }
397 |
398 |
399 | /* ---------- Stream option methods ---------------------------------------------- */
400 |
401 | /**
402 | * Set time out on connection.
403 | * @param int $seconds Timeout part in seconds
404 | * @param int $microseconds Timeout part in microseconds
405 | * @return bool
406 | */
407 | public function setTimeout(int $seconds, int $microseconds = 0): bool
408 | {
409 | $this->logger->debug("Setting timeout {$seconds}:{$microseconds} seconds");
410 | return stream_set_timeout($this->stream, $seconds, $microseconds);
411 | }
412 |
413 |
414 | /* ---------- Stream read/write methods ------------------------------------------ */
415 |
416 | /**
417 | * Read line from stream.
418 | * @param int $length Maximum number of bytes to read
419 | * @param string $ending Line delimiter
420 | * @return string Read data
421 | */
422 | public function getLine(int $length, string $ending): string
423 | {
424 | $line = stream_get_line($this->stream, $length, $ending);
425 | if ($line === false) {
426 | $this->throwException('Could not read from stream');
427 | }
428 | $read = strlen($line);
429 | $this->logger->debug("Read {$read} bytes of line.");
430 | return $line;
431 | }
432 |
433 | /**
434 | * Read line from stream.
435 | * @param int $length Maximum number of bytes to read
436 | * @return string Read data
437 | */
438 | public function gets(int $length): string
439 | {
440 | $line = fgets($this->stream, $length);
441 | if ($line === false) {
442 | $this->throwException('Could not read from stream');
443 | }
444 | $read = strlen($line);
445 | $this->logger->debug("Read {$read} bytes of line.");
446 | return $line;
447 | }
448 |
449 | /**
450 | * Read characters from stream.
451 | * @param int $length Maximum number of bytes to read
452 | * @return string Read data
453 | */
454 | public function read(string $length): string
455 | {
456 | $data = '';
457 | while (strlen($data) < $length) {
458 | $buffer = fread($this->stream, $length - strlen($data));
459 | if (!$buffer) {
460 | $meta = stream_get_meta_data($this->stream);
461 | if (!empty($meta['timed_out'])) {
462 | $message = 'Client read timeout';
463 | $this->logger->error($message, $meta);
464 | throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta);
465 | }
466 | }
467 | if ($buffer === false) {
468 | $read = strlen($data);
469 | $this->throwException("Broken frame, read {$read} of stated {$length} bytes.");
470 | }
471 | if ($buffer === '') {
472 | $this->throwException("Empty read; connection dead?");
473 | }
474 | $data .= $buffer;
475 | $read = strlen($data);
476 | $this->logger->debug("Read {$read} of {$length} bytes.");
477 | }
478 | return $data;
479 | }
480 |
481 | /**
482 | * Write characters to stream.
483 | * @param string $data Data to read
484 | */
485 | public function write(string $data): void
486 | {
487 | $length = strlen($data);
488 | $written = fwrite($this->stream, $data);
489 | if ($written === false) {
490 | $this->throwException("Failed to write {$length} bytes.");
491 | }
492 | if ($written < strlen($data)) {
493 | $this->throwException("Could only write {$written} out of {$length} bytes.");
494 | }
495 | $this->logger->debug("Wrote {$written} of {$length} bytes.");
496 | }
497 |
498 |
499 | /* ---------- Internal helper methods -------------------------------------------- */
500 |
501 | private function throwException(string $message, int $code = 0): void
502 | {
503 | $meta = ['closed' => true];
504 | if ($this->isConnected()) {
505 | $meta = $this->getMeta();
506 | $this->disconnect();
507 | if (!empty($meta['timed_out'])) {
508 | $this->logger->error($message, $meta);
509 | throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta);
510 | }
511 | if (!empty($meta['eof'])) {
512 | $code = ConnectionException::EOF;
513 | }
514 | }
515 | $this->logger->error($message, $meta);
516 | throw new ConnectionException($message, $code, $meta);
517 | }
518 | }
519 |
--------------------------------------------------------------------------------
/lib/ConnectionException.php:
--------------------------------------------------------------------------------
1 | data = $data;
27 | }
28 |
29 | public function getData(): array
30 | {
31 | return $this->data;
32 | }
33 | }
34 |
--------------------------------------------------------------------------------
/lib/Exception.php:
--------------------------------------------------------------------------------
1 | payload = $payload;
23 | $this->timestamp = new DateTime();
24 | }
25 |
26 | public function getOpcode(): string
27 | {
28 | return $this->opcode;
29 | }
30 |
31 | public function getLength(): int
32 | {
33 | return strlen($this->payload);
34 | }
35 |
36 | public function getTimestamp(): DateTime
37 | {
38 | return $this->timestamp;
39 | }
40 |
41 | public function getContent(): string
42 | {
43 | return $this->payload;
44 | }
45 |
46 | public function setContent(string $payload = ''): void
47 | {
48 | $this->payload = $payload;
49 | }
50 |
51 | public function hasContent(): bool
52 | {
53 | return $this->payload != '';
54 | }
55 |
56 | public function __toString(): string
57 | {
58 | return get_class($this);
59 | }
60 |
61 | // Split messages into frames
62 | public function getFrames(bool $masked = true, int $framesize = 4096): array
63 | {
64 |
65 | $frames = [];
66 | $split = str_split($this->getContent(), $framesize) ?: [''];
67 | foreach ($split as $payload) {
68 | $frames[] = [false, $payload, 'continuation', $masked];
69 | }
70 | $frames[0][2] = $this->opcode;
71 | $frames[array_key_last($frames)][0] = true;
72 | return $frames;
73 | }
74 | }
75 |
--------------------------------------------------------------------------------
/lib/Message/Ping.php:
--------------------------------------------------------------------------------
1 | 0,
16 | 'text' => 1,
17 | 'binary' => 2,
18 | 'close' => 8,
19 | 'ping' => 9,
20 | 'pong' => 10,
21 | ];
22 | }
23 |
--------------------------------------------------------------------------------
/lib/Server.php:
--------------------------------------------------------------------------------
1 | ['text', 'binary'],
32 | 'fragment_size' => 4096,
33 | 'logger' => null,
34 | 'port' => 8000,
35 | 'return_obj' => false,
36 | 'timeout' => null,
37 | ];
38 |
39 | protected $port;
40 | protected $listening;
41 | protected $request;
42 | protected $request_path;
43 | private $connections = [];
44 | private $options = [];
45 | private $listen = false;
46 | private $last_opcode;
47 |
48 |
49 | /* ---------- Magic methods ------------------------------------------------------ */
50 |
51 | /**
52 | * @param array $options
53 | * Associative array containing:
54 | * - filter: Array of opcodes to handle. Default: ['text', 'binary'].
55 | * - fragment_size: Set framgemnt size. Default: 4096
56 | * - logger: PSR-3 compatible logger. Default NullLogger.
57 | * - port: Chose port for listening. Default 8000.
58 | * - return_obj: If receive() function return Message instance. Default false.
59 | * - timeout: Set the socket timeout in seconds.
60 | */
61 | public function __construct(array $options = [])
62 | {
63 | $this->options = array_merge(self::$default_options, [
64 | 'logger' => new NullLogger(),
65 | ], $options);
66 | $this->port = $this->options['port'];
67 | $this->setLogger($this->options['logger']);
68 |
69 | $error = $errno = $errstr = null;
70 | set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) {
71 | $this->logger->warning($message, ['severity' => $severity]);
72 | $error = $message;
73 | }, E_ALL);
74 |
75 | do {
76 | $this->listening = stream_socket_server("tcp://0.0.0.0:$this->port", $errno, $errstr);
77 | } while ($this->listening === false && $this->port++ < 10000);
78 |
79 | restore_error_handler();
80 |
81 | if (!$this->listening) {
82 | $error = "Could not open listening socket: {$errstr} ({$errno}) {$error}";
83 | $this->logger->error($error);
84 | throw new ConnectionException($error, (int)$errno);
85 | }
86 |
87 | $this->logger->info("Server listening to port {$this->port}");
88 | }
89 |
90 | /**
91 | * Get string representation of instance.
92 | * @return string String representation.
93 | */
94 | public function __toString(): string
95 | {
96 | return sprintf(
97 | "%s(%s)",
98 | get_class($this),
99 | $this->getName() ?: 'closed'
100 | );
101 | }
102 |
103 |
104 | /* ---------- Server operations -------------------------------------------------- */
105 |
106 | /**
107 | * Accept a single incoming request.
108 | * Note that this operation will block accepting additional requests.
109 | * @return bool True if listening.
110 | */
111 | public function accept(): bool
112 | {
113 | $this->disconnect();
114 | return (bool)$this->listening;
115 | }
116 |
117 |
118 | /* ---------- Server option functions -------------------------------------------- */
119 |
120 | /**
121 | * Get current port.
122 | * @return int port.
123 | */
124 | public function getPort(): int
125 | {
126 | return $this->port;
127 | }
128 |
129 | /**
130 | * Set timeout.
131 | * @param int $timeout Timeout in seconds.
132 | */
133 | public function setTimeout(int $timeout): void
134 | {
135 | $this->options['timeout'] = $timeout;
136 | if (!$this->isConnected()) {
137 | return;
138 | }
139 | foreach ($this->connections as $connection) {
140 | $connection->setTimeout($timeout);
141 | $connection->setOptions($this->options);
142 | }
143 | }
144 |
145 | /**
146 | * Set fragmentation size.
147 | * @param int $fragment_size Fragment size in bytes.
148 | * @return self.
149 | */
150 | public function setFragmentSize(int $fragment_size): self
151 | {
152 | $this->options['fragment_size'] = $fragment_size;
153 | foreach ($this->connections as $connection) {
154 | $connection->setOptions($this->options);
155 | }
156 | return $this;
157 | }
158 |
159 | /**
160 | * Get fragmentation size.
161 | * @return int $fragment_size Fragment size in bytes.
162 | */
163 | public function getFragmentSize(): int
164 | {
165 | return $this->options['fragment_size'];
166 | }
167 |
168 |
169 | /* ---------- Connection broadcast operations ------------------------------------ */
170 |
171 | /**
172 | * Broadcast text message to all conenctions.
173 | * @param string $payload Content as string.
174 | */
175 | public function text(string $payload): void
176 | {
177 | $this->send($payload);
178 | }
179 |
180 | /**
181 | * Broadcast binary message to all conenctions.
182 | * @param string $payload Content as binary string.
183 | */
184 | public function binary(string $payload): void
185 | {
186 | $this->send($payload, 'binary');
187 | }
188 |
189 | /**
190 | * Broadcast ping message to all conenctions.
191 | * @param string $payload Optional text as string.
192 | */
193 | public function ping(string $payload = ''): void
194 | {
195 | $this->send($payload, 'ping');
196 | }
197 |
198 | /**
199 | * Broadcast pong message to all conenctions.
200 | * @param string $payload Optional text as string.
201 | */
202 | public function pong(string $payload = ''): void
203 | {
204 | $this->send($payload, 'pong');
205 | }
206 |
207 | /**
208 | * Send message on all connections.
209 | * @param string $payload Message to send.
210 | * @param string $opcode Opcode to use, default: 'text'.
211 | * @param bool $masked If message should be masked default: true.
212 | */
213 | public function send(string $payload, string $opcode = 'text', bool $masked = true): void
214 | {
215 | if (!$this->isConnected()) {
216 | $this->connect();
217 | }
218 | if (!in_array($opcode, array_keys(self::$opcodes))) {
219 | $warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
220 | $this->logger->warning($warning);
221 | throw new BadOpcodeException($warning);
222 | }
223 |
224 | $factory = new Factory();
225 | $message = $factory->create($opcode, $payload);
226 |
227 | foreach ($this->connections as $connection) {
228 | $connection->pushMessage($message, $masked);
229 | }
230 | }
231 |
232 | /**
233 | * Close all connections.
234 | * @param int $status Close status, default: 1000.
235 | * @param string $message Close message, default: 'ttfn'.
236 | */
237 | public function close(int $status = 1000, string $message = 'ttfn'): void
238 | {
239 | foreach ($this->connections as $connection) {
240 | if ($connection->isConnected()) {
241 | $connection->close($status, $message);
242 | }
243 | }
244 | }
245 |
246 | /**
247 | * Disconnect all connections.
248 | */
249 | public function disconnect(): void
250 | {
251 | foreach ($this->connections as $connection) {
252 | if ($connection->isConnected()) {
253 | $connection->disconnect();
254 | }
255 | }
256 | $this->connections = [];
257 | }
258 |
259 | /**
260 | * Receive message from single connection.
261 | * Note that this operation will block reading and only read from first available connection.
262 | * @return mixed Message, text or null depending on settings.
263 | */
264 | public function receive()
265 | {
266 | $filter = $this->options['filter'];
267 | $return_obj = $this->options['return_obj'];
268 |
269 | if (!$this->isConnected()) {
270 | $this->connect();
271 | }
272 | $connection = current($this->connections);
273 |
274 | while (true) {
275 | $message = $connection->pullMessage();
276 | $opcode = $message->getOpcode();
277 | if (in_array($opcode, $filter)) {
278 | $this->last_opcode = $opcode;
279 | $return = $return_obj ? $message : $message->getContent();
280 | break;
281 | } elseif ($opcode == 'close') {
282 | $this->last_opcode = null;
283 | $return = $return_obj ? $message : null;
284 | break;
285 | }
286 | }
287 | return $return;
288 | }
289 |
290 |
291 | /* ---------- Connection functions ----------------------------------------------- */
292 |
293 | /**
294 | * Get requested path from last connection.
295 | * @return string Path.
296 | */
297 | public function getPath(): string
298 | {
299 | return $this->request_path;
300 | }
301 |
302 | /**
303 | * Get request from last connection.
304 | * @return array Request.
305 | */
306 | public function getRequest(): array
307 | {
308 | return $this->request;
309 | }
310 |
311 | /**
312 | * Get headers from last connection.
313 | * @return string|null Headers.
314 | */
315 | public function getHeader($header): ?string
316 | {
317 | foreach ($this->request as $row) {
318 | if (stripos($row, $header) !== false) {
319 | list($headername, $headervalue) = explode(":", $row);
320 | return trim($headervalue);
321 | }
322 | }
323 | return null;
324 | }
325 |
326 | /**
327 | * Get last received opcode.
328 | * @return string|null Opcode.
329 | */
330 | public function getLastOpcode(): ?string
331 | {
332 | return $this->last_opcode;
333 | }
334 |
335 | /**
336 | * Get close status from single connection.
337 | * @return int|null Close status.
338 | */
339 | public function getCloseStatus(): ?int
340 | {
341 | return $this->connections ? current($this->connections)->getCloseStatus() : null;
342 | }
343 |
344 | /**
345 | * If Server has active connections.
346 | * @return bool True if active connection.
347 | */
348 | public function isConnected(): bool
349 | {
350 | foreach ($this->connections as $connection) {
351 | if ($connection->isConnected()) {
352 | return true;
353 | }
354 | }
355 | return false;
356 | }
357 |
358 | /**
359 | * Get name of local socket from single connection.
360 | * @return string|null Name of local socket.
361 | */
362 | public function getName(): ?string
363 | {
364 | return $this->isConnected() ? current($this->connections)->getName() : null;
365 | }
366 |
367 | /**
368 | * Get name of remote socket from single connection.
369 | * @return string|null Name of remote socket.
370 | */
371 | public function getRemoteName(): ?string
372 | {
373 | return $this->isConnected() ? current($this->connections)->getRemoteName() : null;
374 | }
375 |
376 | /**
377 | * @deprecated Will be removed in future version.
378 | */
379 | public function getPier(): ?string
380 | {
381 | trigger_error(
382 | 'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
383 | E_USER_DEPRECATED
384 | );
385 | return $this->getRemoteName();
386 | }
387 |
388 |
389 | /* ---------- Helper functions --------------------------------------------------- */
390 |
391 | // Connect when read/write operation is performed.
392 | private function connect(): void
393 | {
394 | try {
395 | $handler = new ErrorHandler();
396 | $socket = $handler->with(function () {
397 | if (isset($this->options['timeout'])) {
398 | $socket = stream_socket_accept($this->listening, $this->options['timeout']);
399 | } else {
400 | $socket = stream_socket_accept($this->listening);
401 | }
402 | if (!$socket) {
403 | throw new ErrorException('No socket');
404 | }
405 | return $socket;
406 | });
407 | } catch (ErrorException $e) {
408 | $error = "Server failed to connect. {$e->getMessage()}";
409 | $this->logger->error($error, ['severity' => $e->getSeverity()]);
410 | throw new ConnectionException($error, 0, [], $e);
411 | }
412 |
413 | $connection = new Connection($socket, $this->options);
414 | $connection->setLogger($this->logger);
415 |
416 | if (isset($this->options['timeout'])) {
417 | $connection->setTimeout($this->options['timeout']);
418 | }
419 |
420 | $this->logger->info("Client has connected to port {port}", [
421 | 'port' => $this->port,
422 | 'peer' => $connection->getRemoteName(),
423 | ]);
424 | $this->performHandshake($connection);
425 | $this->connections = ['*' => $connection];
426 | }
427 |
428 | // Perform upgrade handshake on new connections.
429 | private function performHandshake(Connection $connection): void
430 | {
431 | $request = '';
432 | do {
433 | $buffer = $connection->getLine(1024, "\r\n");
434 | $request .= $buffer . "\n";
435 | $metadata = $connection->getMeta();
436 | } while (!$connection->eof() && $metadata['unread_bytes'] > 0);
437 |
438 | if (!preg_match('/GET (.*) HTTP\//mUi', $request, $matches)) {
439 | $error = "No GET in request: {$request}";
440 | $this->logger->error($error);
441 | throw new ConnectionException($error);
442 | }
443 | $get_uri = trim($matches[1]);
444 | $uri_parts = parse_url($get_uri);
445 |
446 | $this->request = explode("\n", $request);
447 | $this->request_path = $uri_parts['path'];
448 | /// @todo Get query and fragment as well.
449 |
450 | if (!preg_match('#Sec-WebSocket-Key:\s(.*)$#mUi', $request, $matches)) {
451 | $error = "Client had no Key in upgrade request: {$request}";
452 | $this->logger->error($error);
453 | throw new ConnectionException($error);
454 | }
455 |
456 | $key = trim($matches[1]);
457 |
458 | /// @todo Validate key length and base 64...
459 | $response_key = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
460 |
461 | $header = "HTTP/1.1 101 Switching Protocols\r\n"
462 | . "Upgrade: websocket\r\n"
463 | . "Connection: Upgrade\r\n"
464 | . "Sec-WebSocket-Accept: $response_key\r\n"
465 | . "\r\n";
466 |
467 | $connection->write($header);
468 | $this->logger->debug("Handshake on {$get_uri}");
469 | }
470 | }
471 |
--------------------------------------------------------------------------------
/lib/TimeoutException.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | lib/
6 |
7 |
8 |
9 |
10 | tests
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/tests/ExceptionTest.php:
--------------------------------------------------------------------------------
1 | 'with data'],
28 | new TimeoutException(
29 | 'Nested exception',
30 | ConnectionException::TIMED_OUT
31 | )
32 | );
33 | } catch (Throwable $e) {
34 | }
35 |
36 | $this->assertInstanceOf('WebSocket\ConnectionException', $e);
37 | $this->assertInstanceOf('WebSocket\Exception', $e);
38 | $this->assertInstanceOf('Exception', $e);
39 | $this->assertInstanceOf('Throwable', $e);
40 | $this->assertEquals('An error message', $e->getMessage());
41 | $this->assertEquals(1025, $e->getCode());
42 | $this->assertEquals(['test' => 'with data'], $e->getData());
43 |
44 | $p = $e->getPrevious();
45 | $this->assertInstanceOf('WebSocket\TimeoutException', $p);
46 | $this->assertInstanceOf('WebSocket\ConnectionException', $p);
47 | $this->assertEquals('Nested exception', $p->getMessage());
48 | $this->assertEquals(1024, $p->getCode());
49 | $this->assertEquals([], $p->getData());
50 | }
51 | }
52 |
--------------------------------------------------------------------------------
/tests/MessageTest.php:
--------------------------------------------------------------------------------
1 | create('text', 'Some content');
26 | $this->assertInstanceOf('WebSocket\Message\Text', $message);
27 | $message = $factory->create('binary', 'Some content');
28 | $this->assertInstanceOf('WebSocket\Message\Binary', $message);
29 | $message = $factory->create('ping', 'Some content');
30 | $this->assertInstanceOf('WebSocket\Message\Ping', $message);
31 | $message = $factory->create('pong', 'Some content');
32 | $this->assertInstanceOf('WebSocket\Message\Pong', $message);
33 | $message = $factory->create('close', 'Some content');
34 | $this->assertInstanceOf('WebSocket\Message\Close', $message);
35 | }
36 |
37 | public function testMessage()
38 | {
39 | $message = new Text('Some content');
40 | $this->assertInstanceOf('WebSocket\Message\Message', $message);
41 | $this->assertInstanceOf('WebSocket\Message\Text', $message);
42 | $this->assertEquals('Some content', $message->getContent());
43 | $this->assertEquals('text', $message->getOpcode());
44 | $this->assertEquals(12, $message->getLength());
45 | $this->assertTrue($message->hasContent());
46 | $this->assertInstanceOf('DateTime', $message->getTimestamp());
47 | $message->setContent('');
48 | $this->assertEquals(0, $message->getLength());
49 | $this->assertFalse($message->hasContent());
50 | $this->assertEquals('WebSocket\Message\Text', "{$message}");
51 | }
52 |
53 | public function testBadOpcode()
54 | {
55 | $factory = new Factory();
56 | $this->expectException('WebSocket\BadOpcodeException');
57 | $this->expectExceptionMessage("Invalid opcode 'invalid' provided");
58 | $message = $factory->create('invalid', 'Some content');
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/tests/README.md:
--------------------------------------------------------------------------------
1 | # Testing
2 |
3 | Unit tests with [PHPUnit](https://phpunit.readthedocs.io/).
4 |
5 |
6 | ## How to run
7 |
8 | To run all test, run in console.
9 |
10 | ```
11 | make test
12 | ```
13 |
14 |
15 | ## Continuous integration
16 |
17 | GitHub Actions are run on PHP versions `7.4`, `8.0`, `8.1` and `8.2`.
18 |
19 | Code coverage by [Coveralls](https://coveralls.io/github/Textalk/websocket-php).
20 |
21 |
22 | ## Test strategy
23 |
24 | Test set up overloads various stream and socket functions,
25 | and use "scripts" to define and mock input/output of these functions.
26 |
27 | This set up negates the dependency on running servers,
28 | and allow testing various errors that might occur.
29 |
--------------------------------------------------------------------------------
/tests/bootstrap.php:
--------------------------------------------------------------------------------
1 | interpolate($message, $context);
16 | $context_string = empty($context) ? '' : json_encode($context);
17 | echo str_pad($level, 8) . " | {$message} {$context_string}\n";
18 | }
19 |
20 | public function interpolate($message, array $context = [])
21 | {
22 | // Build a replacement array with braces around the context keys
23 | $replace = [];
24 | foreach ($context as $key => $val) {
25 | // Check that the value can be cast to string
26 | if (!is_array($val) && (!is_object($val) || method_exists($val, '__toString'))) {
27 | $replace['{' . $key . '}'] = $val;
28 | }
29 | }
30 |
31 | // Interpolate replacement values into the message and return
32 | return strtr($message, $replace);
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/tests/mock/MockSocket.php:
--------------------------------------------------------------------------------
1 | assertEquals($current['function'], $function);
23 | foreach ($current['params'] as $index => $param) {
24 | if (isset($current['input-op'])) {
25 | $param = self::op($current['input-op'], $params, $param);
26 | }
27 | self::$asserter->assertEquals($param, $params[$index], json_encode([$current, $params]));
28 | }
29 | if (isset($current['error'])) {
30 | $map = array_merge(['msg' => 'Error', 'type' => E_USER_NOTICE], (array)$current['error']);
31 | trigger_error($map['msg'], $map['type']);
32 | }
33 | if (isset($current['return-op'])) {
34 | return self::op($current['return-op'], $params, $current['return']);
35 | }
36 | if (isset($current['return'])) {
37 | return $current['return'];
38 | }
39 | return call_user_func_array($function, $params);
40 | }
41 |
42 | // Check if all expected calls are performed
43 | public static function isEmpty(): bool
44 | {
45 | return empty(self::$queue);
46 | }
47 |
48 | // Initialize call queue
49 | public static function initialize($op_file, $asserter): void
50 | {
51 | $file = dirname(__DIR__) . "/scripts/{$op_file}.json";
52 | self::$queue = json_decode(file_get_contents($file), true);
53 | self::$asserter = $asserter;
54 | }
55 |
56 | // Special output handling
57 | private static function op($op, $params, $data)
58 | {
59 | switch ($op) {
60 | case 'chr-array':
61 | // Convert int array to string
62 | $out = '';
63 | foreach ($data as $val) {
64 | $out .= chr($val);
65 | }
66 | return $out;
67 | case 'file':
68 | $content = file_get_contents(__DIR__ . "/{$data[0]}");
69 | return substr($content, $data[1], $data[2]);
70 | case 'key-save':
71 | preg_match('#Sec-WebSocket-Key:\s(.*)$#mUi', $params[1], $matches);
72 | self::$stored['sec-websocket-key'] = trim($matches[1]);
73 | return str_replace('{key}', self::$stored['sec-websocket-key'], $data);
74 | case 'key-respond':
75 | $key = self::$stored['sec-websocket-key'] . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
76 | $encoded = base64_encode(pack('H*', sha1($key)));
77 | return str_replace('{key}', $encoded, $data);
78 | }
79 | return $data;
80 | }
81 | }
82 |
--------------------------------------------------------------------------------
/tests/mock/mock-socket.php:
--------------------------------------------------------------------------------
1 |