├── VERSION
├── _config.yml
├── .codedocs
├── .idea
├── encodings.xml
├── codeStyles
│ └── codeStyleConfig.xml
├── vcs.xml
├── modules.xml
├── php-test-framework.xml
├── php.xml
└── PHPWebSockets.iml
├── Resources
├── Autobahn
│ ├── fuzzingserver.json
│ └── fuzzingclient.json
└── Doxygen
│ └── File.doxy
├── .editorconfig
├── phpunit.xml.dist
├── .codeclimate.yml
├── LICENSE
├── composer.json
├── src
├── PHPWebSockets
│ ├── ITaggable.php
│ ├── TLogAware.php
│ ├── IStreamContainer.php
│ ├── TStreamContainerDefaults.php
│ ├── AUpdate.php
│ ├── Update
│ │ ├── Error.php
│ │ └── Read.php
│ ├── Server
│ │ ├── AcceptingConnection.php
│ │ └── Connection.php
│ ├── Framer.php
│ ├── Client.php
│ ├── Server.php
│ └── UpdatesWrapper.php
└── PHPWebSockets.php
├── .github
└── workflows
│ └── Main.yaml
├── README.md
├── tests
├── Helpers
│ └── client.php
├── ServerTest.php
├── ClientTest.php
└── UpdatesWrapperTest.php
└── .gitignore
/VERSION:
--------------------------------------------------------------------------------
1 | 4.0.1
2 |
--------------------------------------------------------------------------------
/_config.yml:
--------------------------------------------------------------------------------
1 | theme: jekyll-theme-slate
--------------------------------------------------------------------------------
/.codedocs:
--------------------------------------------------------------------------------
1 | # CodeDocs.xyz Configuration File
2 |
3 | DOXYFILE = Resources/Doxygen/File.doxy
4 | # PROJECT_LOGO = Resources/Doxygen/Logo.png
5 |
--------------------------------------------------------------------------------
/.idea/encodings.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
--------------------------------------------------------------------------------
/.idea/codeStyles/codeStyleConfig.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
--------------------------------------------------------------------------------
/Resources/Autobahn/fuzzingserver.json:
--------------------------------------------------------------------------------
1 | {
2 | "url": "ws://127.0.0.1:9001",
3 | "outdir": "/reports/",
4 | "cases": ["*"],
5 | "exclude-cases": [],
6 | "exclude-agent-cases": {}
7 | }
8 |
--------------------------------------------------------------------------------
/.idea/vcs.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
--------------------------------------------------------------------------------
/Resources/Autobahn/fuzzingclient.json:
--------------------------------------------------------------------------------
1 | {
2 | "outdir": "/reports/",
3 | "servers": [
4 | {
5 | "url": "ws://host.docker.internal:9001"
6 | }
7 | ],
8 | "cases": ["*"],
9 | "exclude-cases": [],
10 | "exclude-agent-cases": {}
11 | }
12 |
--------------------------------------------------------------------------------
/.idea/modules.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
--------------------------------------------------------------------------------
/.idea/php-test-framework.xml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 |
2 | root = true
3 |
4 | [*.{php, json}]
5 | charset = utf-8
6 | end_of_line = lf
7 | indent_size = 4
8 | indent_style = space
9 | insert_final_newline = true
10 | trim_trailing_whitespace = true
11 |
12 | [*.md]
13 | charset = utf-8
14 | end_of_line = lf
15 | trim_trailing_whitespace = false
16 |
17 | [*.{yml,yaml}]
18 | charset = utf-8
19 | end_of_line = lf
20 | indent_size = 2
21 | indent_style = space
22 | insert_final_newline = true
23 | trim_trailing_whitespace = false
24 |
--------------------------------------------------------------------------------
/phpunit.xml.dist:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | ./tests
7 | ./tests/ClientTest.php
8 | ./tests/ServerTest.php
9 |
10 |
11 |
12 |
--------------------------------------------------------------------------------
/.codeclimate.yml:
--------------------------------------------------------------------------------
1 |
2 | engines:
3 | duplication:
4 | enabled: true
5 | config:
6 | languages:
7 | - php
8 | fixme:
9 | enabled: true
10 | phpmd:
11 | enabled: true
12 | checks:
13 | Controversial/CamelCaseMethodName:
14 | enabled: false
15 | Controversial/CamelCasePropertyName:
16 | enabled: false
17 | CleanCode/BooleanArgumentFlag:
18 | enabled: false
19 | CleanCode/ElseExpression:
20 | enabled: false
21 | CleanCode/StaticAccess:
22 | enabled: false
23 | CyclomaticComplexity:
24 | enabled: false
25 | Design/NpathComplexity:
26 | enabled: false
27 | ratings:
28 | paths:
29 | - "**.php"
30 | exclude_paths:
31 | - "Resources/**"
32 | - "tests/**"
33 | - "vendor/**"
34 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Kevin Meijer
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/composer.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "warriorxk/phpwebsockets",
3 | "type": "library",
4 | "description": "A websocket library with support for IPC using socket pairs",
5 | "keywords": ["websocket", "php", "phpwebsockets", "ipc", "socket", "client", "server"],
6 | "homepage": "https://github.com/WarriorXK/PHPWebSockets",
7 | "license": "MIT",
8 | "authors": [
9 | {
10 | "name": "Kevin Meijer",
11 | "email": "admin@kevinmeijer.nl",
12 | "homepage": "https://kevinmeijer.nl",
13 | "role": "Developer"
14 | }
15 | ],
16 | "require-dev": {
17 | "phpunit/phpunit": "^9.6",
18 | "ext-pcntl": "*",
19 | "ext-json": "*"
20 | },
21 | "require": {
22 | "php": "^7.4.0 || ^8.0.0",
23 | "ext-sockets": "*",
24 | "psr/log": "^1.0 || ^2.0 || ^3.0"
25 | },
26 | "autoload": {
27 | "psr-4": {
28 | "PHPWebSockets\\": [
29 | "src/PHPWebSockets/"
30 | ]
31 | },
32 | "classmap": [
33 | "src/PHPWebSockets.php"
34 | ]
35 | },
36 | "archive": {
37 | "exclude": [
38 | "/*",
39 | "/.*",
40 | "!/src",
41 | "!/VERSION",
42 | "!/.editorconfig"
43 | ]
44 | },
45 | "non-feature-branches": [
46 | "develop",
47 | "master"
48 | ],
49 | "config": {
50 | "platform": {
51 | "php": "8.1"
52 | }
53 | }
54 | }
55 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/ITaggable.php:
--------------------------------------------------------------------------------
1 | /dev/null
31 | - name: Apt update
32 | run: sudo apt-get update
33 | - name: Install docker
34 | run: sudo apt install docker-ce
35 | # Run PHPUnit
36 | - name: PHPUnit
37 | run: ./vendor/bin/phpunit ${{ matrix.test }}
38 | env:
39 | BUFFERTYPE: ${{ matrix.buffer_type }}
40 | PHPUnit-Other:
41 | runs-on: ubuntu-22.04
42 | strategy:
43 | matrix:
44 | php_version: [ 7.4, 8.0, 8.1, 8.2 ]
45 | steps:
46 | # Install PHP
47 | - name: Setup PHP with PECL extension
48 | uses: shivammathur/setup-php@v2
49 | with:
50 | php-version: ${{ matrix.php_version }}
51 | extensions: pcntl
52 | # Clone code
53 | - uses: actions/checkout@v3
54 | # Composer install
55 | - uses: php-actions/composer@v6
56 | with:
57 | args: --ignore-platform-reqs
58 | # Run PHPUnit
59 | - name: PHPUnit
60 | run: ./vendor/bin/phpunit ${{ matrix.test }}
61 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/TLogAware.php:
--------------------------------------------------------------------------------
1 | _logger = $logger;
48 | }
49 |
50 | /**
51 | * Returns the logger to use
52 | *
53 | * @return \Psr\Log\LoggerInterface
54 | */
55 | public function getLogger() : ?LoggerInterface {
56 | if ($this->_logger === NULL) {
57 | $this->_logger = \PHPWebSockets::GetLogger();
58 | }
59 |
60 | return $this->_logger;
61 | }
62 |
63 | /**
64 | * Logs a message to set logger
65 | *
66 | * @param string $level
67 | * @param string $message
68 | * @param array $context
69 | *
70 | * @return void
71 | */
72 | protected function _log(string $level, string $message, array $context = []) : void {
73 | $logger = $this->getLogger();
74 | if ($logger) {
75 | $logger->log($level, $message, array_merge([
76 | 'subject' => $this,
77 | ], $context));
78 | }
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # PHPWebSockets
2 | [](https://codedocs.xyz/WarriorXK/PHPWebSockets/) Master: [](https://travis-ci.com/WarriorXK/PHPWebSockets) Develop: [](https://travis-ci.com/WarriorXK/PHPWebSockets)
3 |
4 | A PHP library to accept and create websocket connections, we aim to be 100% compliant with the websocket RFC and use the [Autobahn test suite](http://autobahn.ws/testsuite/) to ensure so.
5 | Currently the server and the client are 100% compliant with the autobahn testsuite minus a few non-strict notices, the [compression extension](https://tools.ietf.org/html/rfc7692) for websockets will be implemented later
6 |
7 | ## Server
8 | For websocket servers a new \PHPWebSockets\Server instance should be created with a bind address and a port to listen on.
9 | For ease of use you can use the UpdatesWrapper class, this will trigger certain callables set on basic functions.
10 |
11 | A basic websocket echo server would be:
12 |
13 | ```php
14 | require_once __DIR__ . '/vendor/autoload.php';
15 |
16 | $wrapper = new \PHPWebSockets\UpdatesWrapper();
17 | $wrapper->setMessageHandler(function(\PHPWebSockets\AConnection $connection, string $message, int $opcode) {
18 |
19 | echo 'Got message with length ' . strlen($message) . PHP_EOL;
20 | $connection->write($message, $opcode);
21 |
22 | });
23 |
24 |
25 | $server = new \PHPWebSockets\Server('tcp://0.0.0.0:9001');
26 |
27 | while (TRUE) {
28 | $wrapper->update(0.1, $server->getConnections(TRUE));
29 | }
30 | ```
31 |
32 | If more control is required you can manually call ```$server->update(0.1);``` instead of using the wrapper, this will yield update objects which can be responded to.
33 |
34 | ## Client
35 | For connecting to a server the \PHPWebSockets\Client class should be constructed and the method connect($address, $port, $path) should be used to connect.
36 | For ease of use you can again use the UpdatesWrapper class or use ```$server->update(0.1);``` for better control.
37 |
38 | A basic websocket echo client would be:
39 |
40 | ```php
41 | require_once __DIR__ . '/../vendor/autoload.php';
42 |
43 | $wrapper = new \PHPWebSockets\UpdatesWrapper();
44 | $wrapper->setMessageHandler(function(\PHPWebSockets\AConnection $connection, string $message, int $opcode) {
45 |
46 | echo 'Got message with length ' . strlen($message) . PHP_EOL;
47 | $connection->write($message, $opcode);
48 |
49 | });
50 |
51 |
52 | $client = new \PHPWebSockets\Client();
53 | if (!$client->connect('tcp://localhost:9001/webSocket')) {
54 | die('Unable to connect to server: ' . $client->getLastError() . PHP_EOL);
55 | }
56 |
57 | while (TRUE) {
58 | $wrapper->update(0.1, [$client]);
59 | }
60 | ```
61 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/IStreamContainer.php:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/.idea/PHPWebSockets.iml:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
33 |
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 |
42 |
43 |
44 |
45 |
46 |
47 |
48 |
49 |
50 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/AUpdate.php:
--------------------------------------------------------------------------------
1 | _sourceConnection = $sourceConnection;
62 | $this->_code = $code;
63 |
64 | if (\PHPWebSockets::ShouldUpdateTrace($this)) {
65 | $this->_trace = debug_backtrace(0);
66 | }
67 |
68 | }
69 |
70 | /**
71 | * Returns the connection that triggered this update
72 | *
73 | * @return \PHPWebSockets\AConnection|null
74 | */
75 | public function getSourceConnection() : ?AConnection {
76 | return $this->_sourceConnection;
77 | }
78 |
79 | /**
80 | * Sets additional information to pass on for error handling
81 | *
82 | * @param string $info
83 | *
84 | * @return void
85 | */
86 | public function setAdditionalInfo(string $info) : void {
87 | $this->_additionalInfo = $info;
88 | }
89 |
90 | /**
91 | * @return string
92 | */
93 | public function getAdditionalInfo() : string {
94 | return $this->_additionalInfo;
95 | }
96 |
97 | /**
98 | * @return array|null
99 | */
100 | public function getTrace() : ?array {
101 | return $this->_trace;
102 | }
103 |
104 | /**
105 | * Returns the code for this update
106 | *
107 | * @return int
108 | */
109 | public function getCode() : int {
110 | return $this->_code;
111 | }
112 |
113 | public function __toString() {
114 | return 'AUpdate) (C: ' . $this->getCode() . ')' . ($this->_additionalInfo ? ' Additional info: ' . $this->_additionalInfo : '');
115 | }
116 | }
117 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Update/Error.php:
--------------------------------------------------------------------------------
1 | 'Unknown error',
68 | self::C_SELECT => 'Select error',
69 | self::C_READ => 'Read error',
70 | self::C_READ_EMPTY => 'Empty read',
71 | self::C_READ_UNHANDLED => 'Unhandled read',
72 | self::C_READ_HANDSHAKE_FAILURE => 'Handshake failure',
73 | self::C_READ_HANDSHAKE_TO_LARGE => 'Handshake to large',
74 | self::C_READ_INVALID_PAYLOAD => 'Invalid payload',
75 | self::C_READ_INVALID_HEADERS => 'Invalid headers',
76 | self::C_READ_UNEXPECTED_DISCONNECT => 'Unexpected disconnect',
77 | self::C_READ_PROTOCOL_ERROR => 'Protocol error',
78 | self::C_READ_RSV_BIT_SET => 'RSV bit set while not being expected',
79 | self::C_WRITE => 'Write failure',
80 | self::C_ACCEPT_TIMEOUT_PASSED => 'Accept timeout passed',
81 | self::C_READ_DISCONNECT_DURING_HANDSHAKE => 'Disconnect during handshake',
82 | self::C_DISCONNECT_TIMEOUT => 'The remote failed to respond in time to our disconnect',
83 | self::C_READ_NO_STREAM_FOR_NEW_MESSAGE => 'No stream was returned by the newMessageStreamCallback',
84 | self::C_ASYNC_CONNECT_FAILED => 'Async connect failed',
85 | ];
86 |
87 | return $codes[$code] ?? 'Unknown error code ' . $code;
88 | }
89 |
90 | public function __toString() {
91 |
92 | $code = $this->getCode();
93 |
94 | return 'Error) ' . self::StringForCode($code) . ' (C: ' . $code . ')' . ($this->_additionalInfo ? ' Additional info: ' . $this->_additionalInfo : '');
95 | }
96 | }
97 |
--------------------------------------------------------------------------------
/tests/Helpers/client.php:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env php
2 | '',
36 | 'message' => '',
37 | 'message-interval' => 1,
38 | 'message-count' => 0,
39 | 'ping-interval' => 0,
40 | 'die-at' => 0,
41 | 'close-at' => 0,
42 | 'async' => FALSE,
43 | ];
44 |
45 | foreach ($argv as $item) {
46 |
47 | if (substr($item, 0, 10) === '--address=') {
48 | $cliArgs['address'] = substr($item, 10);
49 | } elseif (substr($item, 0, 10) === '--message=') {
50 | $cliArgs['message'] = substr($item, 10);
51 | } elseif (substr($item, 0, 19) === '--message-interval=') {
52 | $cliArgs['message-interval'] = (int) substr($item, 19);
53 | } elseif (substr($item, 0, 16) === '--message-count=') {
54 | $cliArgs['message-count'] = (int) substr($item, 16);
55 | } elseif (substr($item, 0, 16) === '--ping-interval=') {
56 | $cliArgs['ping-interval'] = (int) substr($item, 16);
57 | } elseif (substr($item, 0, 9) === '--die-at=') {
58 | $cliArgs['die-at'] = (float) substr($item, 9);
59 | } elseif (substr($item, 0, 11) === '--close-at=') {
60 | $cliArgs['close-at'] = (float) substr($item, 11);
61 | } elseif ($item === '--async') {
62 | $cliArgs['async'] = TRUE;
63 | }
64 |
65 | }
66 |
67 | $client = new \PHPWebSockets\Client();
68 | if (!$client->connect($cliArgs['address'], '/', [], $cliArgs['async'])) {
69 | throw new \RuntimeException('Unable to connect to ' . $cliArgs['address']);
70 | }
71 |
72 | $messageCount = 0;
73 | $lastMessage = 0;
74 | $pingCount = 0;
75 | $lastPing = 0;
76 | $didCloseOrDisconnect = FALSE;
77 |
78 | while ($client->isOpen()) {
79 |
80 | if ($cliArgs['die-at'] > 0.0 && microtime(TRUE) >= $cliArgs['die-at']) {
81 | exit();
82 | }
83 |
84 | if ($cliArgs['close-at'] > 0.0 && microtime(TRUE) >= $cliArgs['close-at']) {
85 | if (!$didCloseOrDisconnect) {
86 | $client->close();
87 | $didCloseOrDisconnect = TRUE;
88 | }
89 | }
90 |
91 | foreach ($client->update(0.1) as $update) {
92 | // Nothing
93 | }
94 |
95 | if (!$client->hasHandshake()) {
96 | continue;
97 | }
98 |
99 | if ($cliArgs['message-count'] > 0 && $messageCount > $cliArgs['message-count']) {
100 | if (!$didCloseOrDisconnect) {
101 | $client->sendDisconnect(\PHPWebSockets::CLOSECODE_NORMAL);
102 | $didCloseOrDisconnect = TRUE;
103 | }
104 | }
105 |
106 | if (!$client->isOpen() || $client->isDisconnecting()) {
107 | continue;
108 | }
109 |
110 | if ($cliArgs['message'] && ($lastMessage + $cliArgs['message-interval']) < time()) {
111 |
112 | $client->write($cliArgs['message']);
113 |
114 | $lastMessage = microtime(TRUE);
115 | $messageCount++;
116 |
117 | }
118 |
119 | if ($cliArgs['ping-interval'] > 0 && ($lastPing + $cliArgs['ping-interval']) < time()) {
120 |
121 | $client->write('', \PHPWebSockets::OPCODE_PING);
122 |
123 | $lastPing = microtime(TRUE);
124 | $pingCount++;
125 |
126 | }
127 |
128 | }
129 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # Created by https://www.toptal.com/developers/gitignore/api/macos,windows,composer,phpstorm,phpunit
2 | # Edit at https://www.toptal.com/developers/gitignore?templates=macos,windows,composer,phpstorm,phpunit
3 |
4 | ### Composer ###
5 | composer.phar
6 | /vendor/
7 |
8 | # Commit your application's lock file https://getcomposer.org/doc/01-basic-usage.md#commit-your-composer-lock-file-to-version-control
9 | # You may choose to ignore a library lock file http://getcomposer.org/doc/02-libraries.md#lock-file
10 | # composer.lock
11 |
12 | ### macOS ###
13 | # General
14 | .DS_Store
15 | .AppleDouble
16 | .LSOverride
17 |
18 | # Icon must end with two \r
19 | Icon
20 |
21 |
22 | # Thumbnails
23 | ._*
24 |
25 | # Files that might appear in the root of a volume
26 | .DocumentRevisions-V100
27 | .fseventsd
28 | .Spotlight-V100
29 | .TemporaryItems
30 | .Trashes
31 | .VolumeIcon.icns
32 | .com.apple.timemachine.donotpresent
33 |
34 | # Directories potentially created on remote AFP share
35 | .AppleDB
36 | .AppleDesktop
37 | Network Trash Folder
38 | Temporary Items
39 | .apdisk
40 |
41 | ### macOS Patch ###
42 | # iCloud generated files
43 | *.icloud
44 |
45 | ### PhpStorm ###
46 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
47 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
48 |
49 | # User-specific stuff
50 | .idea/**/workspace.xml
51 | .idea/**/tasks.xml
52 | .idea/**/usage.statistics.xml
53 | .idea/**/dictionaries
54 | .idea/**/shelf
55 |
56 | # AWS User-specific
57 | .idea/**/aws.xml
58 |
59 | # Generated files
60 | .idea/**/contentModel.xml
61 |
62 | # Sensitive or high-churn files
63 | .idea/**/dataSources/
64 | .idea/**/dataSources.ids
65 | .idea/**/dataSources.local.xml
66 | .idea/**/sqlDataSources.xml
67 | .idea/**/dynamic.xml
68 | .idea/**/uiDesigner.xml
69 | .idea/**/dbnavigator.xml
70 |
71 | # Gradle
72 | .idea/**/gradle.xml
73 | .idea/**/libraries
74 |
75 | # Gradle and Maven with auto-import
76 | # When using Gradle or Maven with auto-import, you should exclude module files,
77 | # since they will be recreated, and may cause churn. Uncomment if using
78 | # auto-import.
79 | # .idea/artifacts
80 | # .idea/compiler.xml
81 | # .idea/jarRepositories.xml
82 | # .idea/modules.xml
83 | # .idea/*.iml
84 | # .idea/modules
85 | # *.iml
86 | # *.ipr
87 |
88 | # CMake
89 | cmake-build-*/
90 |
91 | # Mongo Explorer plugin
92 | .idea/**/mongoSettings.xml
93 |
94 | # File-based project format
95 | *.iws
96 |
97 | # IntelliJ
98 | out/
99 |
100 | # mpeltonen/sbt-idea plugin
101 | .idea_modules/
102 |
103 | # JIRA plugin
104 | atlassian-ide-plugin.xml
105 |
106 | # Cursive Clojure plugin
107 | .idea/replstate.xml
108 |
109 | # SonarLint plugin
110 | .idea/sonarlint/
111 |
112 | # Crashlytics plugin (for Android Studio and IntelliJ)
113 | com_crashlytics_export_strings.xml
114 | crashlytics.properties
115 | crashlytics-build.properties
116 | fabric.properties
117 |
118 | # Editor-based Rest Client
119 | .idea/httpRequests
120 |
121 | # Android studio 3.1+ serialized cache file
122 | .idea/caches/build_file_checksums.ser
123 |
124 | ### PhpStorm Patch ###
125 | # Comment Reason: https://github.com/joeblau/gitignore.io/issues/186#issuecomment-215987721
126 |
127 | # *.iml
128 | # modules.xml
129 | # .idea/misc.xml
130 | # *.ipr
131 |
132 | # Sonarlint plugin
133 | # https://plugins.jetbrains.com/plugin/7973-sonarlint
134 | .idea/**/sonarlint/
135 |
136 | # SonarQube Plugin
137 | # https://plugins.jetbrains.com/plugin/7238-sonarqube-community-plugin
138 | .idea/**/sonarIssues.xml
139 |
140 | # Markdown Navigator plugin
141 | # https://plugins.jetbrains.com/plugin/7896-markdown-navigator-enhanced
142 | .idea/**/markdown-navigator.xml
143 | .idea/**/markdown-navigator-enh.xml
144 | .idea/**/markdown-navigator/
145 |
146 | # Cache file creation bug
147 | # See https://youtrack.jetbrains.com/issue/JBR-2257
148 | .idea/$CACHE_FILE$
149 |
150 | # CodeStream plugin
151 | # https://plugins.jetbrains.com/plugin/12206-codestream
152 | .idea/codestream.xml
153 |
154 | # Azure Toolkit for IntelliJ plugin
155 | # https://plugins.jetbrains.com/plugin/8053-azure-toolkit-for-intellij
156 | .idea/**/azureSettings.xml
157 |
158 | ### PHPUnit ###
159 | # Covers PHPUnit
160 | # Reference: https://phpunit.de/
161 |
162 | # Generated files
163 | .phpunit.result.cache
164 | .phpunit.cache
165 |
166 | # PHPUnit
167 | /app/phpunit.xml
168 | /phpunit.xml
169 |
170 | # Build data
171 | /build/
172 |
173 | ### Windows ###
174 | # Windows thumbnail cache files
175 | Thumbs.db
176 | Thumbs.db:encryptable
177 | ehthumbs.db
178 | ehthumbs_vista.db
179 |
180 | # Dump file
181 | *.stackdump
182 |
183 | # Folder config file
184 | [Dd]esktop.ini
185 |
186 | # Recycle Bin used on file shares
187 | $RECYCLE.BIN/
188 |
189 | # Windows Installer files
190 | *.cab
191 | *.msi
192 | *.msix
193 | *.msm
194 | *.msp
195 |
196 | # Windows shortcuts
197 | *.lnk
198 |
199 | # End of https://www.toptal.com/developers/gitignore/api/macos,windows,composer,phpstorm,phpunit
200 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Update/Read.php:
--------------------------------------------------------------------------------
1 | _message = $message;
79 | $this->_opcode = $opcode;
80 | $this->_stream = $stream;
81 |
82 | }
83 |
84 | /**
85 | * Returns a description for the provided code
86 | *
87 | * @param int $code
88 | *
89 | * @return string
90 | */
91 | public static function StringForCode(int $code) : string {
92 |
93 | $codes = [
94 | self::C_UNKNOWN => 'Unknown error',
95 | self::C_NEW_CONNECTION => 'New connection',
96 | self::C_READ => 'Read',
97 | self::C_PING => 'Ping',
98 | self::C_PONG => 'Pong',
99 | self::C_SOCK_DISCONNECT => 'Socket disconnected',
100 | self::C_CONNECTION_DENIED => 'Connection denied',
101 | self::C_CONNECTION_ACCEPTED => 'Connection accepted',
102 | self::C_READ_DISCONNECT => 'Disconnect',
103 | self::C_NEW_SOCKET_CONNECTED => 'New connection accepted',
104 | self::C_NEW_SOCKET_CONNECTION_AVAILABLE => 'New connection available',
105 | ];
106 |
107 | return $codes[$code] ?? 'Unknown read code ' . $code;
108 | }
109 |
110 | /**
111 | * Returns the message from the client
112 | *
113 | * @return string|null
114 | */
115 | public function getMessage() : ?string {
116 | return $this->_message;
117 | }
118 |
119 | /**
120 | * Returns the opcode for this message
121 | *
122 | * @return int|null
123 | */
124 | public function getOpcode() : ?int {
125 | return $this->_opcode;
126 | }
127 |
128 | /**
129 | * Returns the resource pointing to the downloaded message
130 | *
131 | * @return resource|null
132 | */
133 | public function getStream() {
134 | return $this->_stream;
135 | }
136 |
137 | public function __toString() {
138 |
139 | $code = $this->getCode();
140 |
141 | return 'Read) ' . self::StringForCode($code) . ' (C: ' . $code . ')' . ($this->_additionalInfo ? ' Additional info: ' . $this->_additionalInfo : '');;
142 | }
143 | }
144 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Server/AcceptingConnection.php:
--------------------------------------------------------------------------------
1 | _server = $server;
63 | $this->_stream = $stream;
64 |
65 | // Inherit the logger from the server
66 | $serverLogger = $server->getLogger();
67 | if ($serverLogger && $serverLogger !== \PHPWebSockets::GetLogger()) {
68 | $this->setLogger($serverLogger);
69 | }
70 |
71 | stream_set_timeout($this->_stream, 1);
72 | stream_set_blocking($this->_stream, FALSE);
73 | stream_set_read_buffer($this->_stream, 0);
74 | stream_set_write_buffer($this->_stream, 0);
75 |
76 | }
77 |
78 | /**
79 | * Handles exceptional data reads
80 | *
81 | * @return \Generator|\PHPWebSockets\AUpdate[]
82 | */
83 | public function handleExceptional() : \Generator {
84 | throw new \LogicException('OOB data is not handled for an accepting stream!');
85 | }
86 |
87 | /**
88 | * Writes the current buffer to the connection
89 | *
90 | * @return \Generator|\PHPWebSockets\AUpdate[]
91 | */
92 | public function handleWrite() : \Generator {
93 | throw new \LogicException('An accepting socket should never write!');
94 | }
95 |
96 | /**
97 | * Attempts to read from our connection
98 | *
99 | * @return \Generator|\PHPWebSockets\AUpdate[]
100 | */
101 | public function handleRead() : \Generator {
102 | yield from $this->_server->gotNewConnection();
103 | }
104 |
105 | /**
106 | * Returns the related websocket server
107 | *
108 | * @return \PHPWebSockets\Server
109 | */
110 | public function getServer() : Server {
111 | return $this->_server;
112 | }
113 |
114 | /**
115 | * Returns the stream object for this connection
116 | *
117 | * @return resource
118 | */
119 | public function getStream() {
120 | return $this->_stream;
121 | }
122 |
123 | /**
124 | * Returns if our connection is open
125 | *
126 | * @return bool
127 | */
128 | public function isOpen() : bool {
129 | return is_resource($this->_stream);
130 | }
131 |
132 | /**
133 | * Closes the stream
134 | *
135 | * @param bool $cleanup If we should remove our unix socket if we used one
136 | *
137 | * @return void
138 | */
139 | public function close(bool $cleanup = TRUE) : void {
140 |
141 | if (is_resource($this->_stream)) {
142 | fclose($this->_stream);
143 | $this->_stream = NULL;
144 | }
145 |
146 | $address = $this->_server->getAddress();
147 | $pos = strpos($address, '://');
148 | if ($pos !== FALSE) {
149 |
150 | $protocol = substr($address, 0, $pos);
151 | switch ($protocol) {
152 | case 'unix':
153 | case 'udg':
154 |
155 | if (!$cleanup) {
156 | $this->_log(LogLevel::DEBUG, 'Not cleaning up');
157 | break;
158 | }
159 |
160 | $path = substr($address, $pos + 3);
161 | if (file_exists($path)) {
162 |
163 | if (!$cleanup) {
164 | $this->_log(LogLevel::DEBUG, 'Not cleaning up ' . $path);
165 | } else {
166 |
167 | $this->_log(LogLevel::DEBUG, 'Unlinking: ' . $path);
168 | unlink($path);
169 |
170 | }
171 |
172 | }
173 |
174 | break;
175 | }
176 |
177 | }
178 |
179 | }
180 |
181 | /**
182 | * @param string|null $tag
183 | */
184 | public function setTag(?string $tag) : void {
185 | $this->_tag = $tag;
186 | }
187 |
188 | /**
189 | * @return string|null
190 | */
191 | public function getTag() : ?string {
192 | return $this->_tag;
193 | }
194 |
195 | public function __destruct() {
196 |
197 | if ($this->isOpen()) {
198 | $this->close();
199 | }
200 |
201 | }
202 |
203 | public function __toString() {
204 |
205 | $tag = $this->getTag();
206 |
207 | return 'AWSConnection #' . (int) $this->getStream() . ($tag === NULL ? '' : ' (Tag: ' . $tag . ')') . ' @ ' . $this->_server;
208 | }
209 | }
210 |
--------------------------------------------------------------------------------
/tests/ServerTest.php:
--------------------------------------------------------------------------------
1 | _bufferType = substr($arg, 11);
90 |
91 | }
92 |
93 | if ($this->_bufferType === NULL) {
94 | $this->_bufferType = getenv('BUFFERTYPE') ?: NULL;
95 | }
96 |
97 | $this->assertContains($this->_bufferType, static::VALID_BUFFER_TYPES, 'Invalid buffer type, env: ' . implode(', ', getenv()));
98 |
99 | \PHPWebSockets::Log(LogLevel::INFO, 'Using buffer type ' . $this->_bufferType);
100 |
101 | $this->_wsServer = new \PHPWebSockets\Server(self::ADDRESS);
102 | $this->_reportsDir = sys_get_temp_dir() . '/ws_reports';
103 | if (!is_dir($this->_reportsDir)) {
104 | mkdir($this->_reportsDir);
105 | }
106 |
107 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
108 | $image = 'crossbario/autobahn-testsuite';
109 | $cmd = 'docker run --rm \
110 | -v "' . realpath(__DIR__ . '/../Resources/Autobahn') . ':/config" \
111 | -v "' . $this->_reportsDir . ':/reports" \
112 | --add-host host.docker.internal:host-gateway \
113 | --name ' . escapeshellarg(static::CONTAINER_NAME) . ' \
114 | ' . $image . ' \
115 | wstest -m fuzzingclient -s /config/fuzzingclient.json
116 | ';
117 |
118 | \PHPWebSockets::Log(LogLevel::INFO, 'Pulling image ' . $image);
119 | passthru('docker pull ' . $image);
120 |
121 | $this->_autobahnProcess = proc_open($cmd, $descriptorSpec, $pipes);
122 |
123 | }
124 |
125 | protected function tearDown() : void {
126 |
127 | \PHPWebSockets::Log(LogLevel::INFO, 'Tearing down');
128 | proc_terminate($this->_autobahnProcess);
129 | exec('docker container stop ' . escapeshellarg(self::CONTAINER_NAME));
130 |
131 | }
132 |
133 | public function testServer() {
134 |
135 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..');
136 |
137 | while (proc_get_status($this->_autobahnProcess)['running'] ?? FALSE) {
138 |
139 | $updates = $this->_wsServer->update(0.1);
140 | foreach ($updates as $update) {
141 |
142 | if ($update instanceof Read) {
143 |
144 | $sourceConn = $update->getSourceConnection();
145 | $opcode = $update->getCode();
146 | switch ($opcode) {
147 | case Read::C_NEW_CONNECTION:
148 |
149 | $sourceConn->accept();
150 |
151 | if ($this->_bufferType === 'tmpfile') {
152 |
153 | $sourceConn->setNewMessageStreamCallback(function (array $headers) {
154 | return tmpfile();
155 | });
156 |
157 | }
158 |
159 | break;
160 | case Read::C_READ:
161 |
162 | $opcode = $update->getOpcode();
163 | switch ($opcode) {
164 | case \PHPWebSockets::OPCODE_CONTINUE:
165 | case \PHPWebSockets::OPCODE_FRAME_TEXT:
166 | case \PHPWebSockets::OPCODE_FRAME_BINARY:
167 |
168 | if ($sourceConn->isDisconnecting()) {
169 | break;
170 | }
171 |
172 | $message = $update->getMessage() ?? '';
173 | if ($message === '') {
174 |
175 | $stream = $update->getStream();
176 | if ($stream) {
177 |
178 | rewind($stream);
179 | $message = stream_get_contents($stream);
180 |
181 | }
182 |
183 | }
184 |
185 | if ($message !== NULL) {
186 | $sourceConn->write($message, $opcode);
187 | }
188 |
189 | break;
190 | }
191 |
192 | break;
193 | }
194 |
195 | }
196 |
197 | }
198 |
199 | }
200 |
201 | \PHPWebSockets::Log(LogLevel::INFO, 'Test ended, closing websocket');
202 |
203 | $this->_wsServer->close();
204 |
205 | \PHPWebSockets::Log(LogLevel::INFO, 'Getting results..');
206 |
207 | $outputFile = $this->_reportsDir . '/index.json';
208 | $this->assertFileExists($outputFile);
209 |
210 | $hasFailures = FALSE;
211 | $testCases = json_decode(file_get_contents($outputFile), TRUE)[$this->_wsServer->getServerIdentifier()] ?? NULL;
212 | $this->assertNotNull($testCases, 'Unable to get test case results');
213 |
214 | foreach ($testCases as $case => $data) {
215 |
216 | \PHPWebSockets::Log(LogLevel::INFO, $case . ' => ' . $data['behavior']);
217 |
218 | switch ($data['behavior']) {
219 | case 'OK':
220 | case 'NON-STRICT':
221 | case 'INFORMATIONAL':
222 | case 'UNIMPLEMENTED':
223 | break;
224 | default:
225 | $hasFailures = TRUE;
226 | break;
227 | }
228 |
229 | }
230 |
231 | $this->assertFalse($hasFailures, 'One or more test cases failed!');
232 |
233 | \PHPWebSockets::Log(LogLevel::INFO, 'Test success');
234 |
235 | }
236 | }
237 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Framer.php:
--------------------------------------------------------------------------------
1 | (bool) ($byte1 & self::BYTE1_FIN),
71 | self::IND_RSV => ($byte1 & self::BYTE1_RSV) >> 4,
72 | self::IND_OPCODE => ($byte1 & self::BYTE1_OPCODE),
73 | self::IND_MASK => (bool) ($byte2 & self::BYTE2_MASKED),
74 | self::IND_LENGTH => ($byte2 & self::BYTE2_LENGTH),
75 | self::IND_MASKING_KEY => NULL,
76 | self::IND_PAYLOAD_OFFSET => 2,
77 | ];
78 |
79 | if ($headers[self::IND_LENGTH] === 126) { // 16 bits
80 |
81 | if ($frameLength < 8) {
82 | return NULL;
83 | }
84 |
85 | $headers[self::IND_LENGTH] = unpack('n', substr($frame, 2, 2))[1];
86 | $headers[self::IND_PAYLOAD_OFFSET] += 2;
87 |
88 | if ($headers[self::IND_MASK]) {
89 | $headers[self::IND_MASKING_KEY] = substr($frame, 4, 4);
90 | }
91 |
92 | } elseif ($headers[self::IND_LENGTH] === 127) { // 64 bits
93 |
94 | if ($frameLength < 14) {
95 | return NULL;
96 | }
97 |
98 | $headers[self::IND_LENGTH] = unpack('J', substr($frame, 2, 8))[1];
99 |
100 | if ($headers[self::IND_MASK]) {
101 | $headers[self::IND_MASKING_KEY] = substr($frame, 10, 4);
102 | }
103 |
104 | $headers[self::IND_PAYLOAD_OFFSET] += 8;
105 |
106 | } elseif ($headers[self::IND_MASK]) { // 7 bits
107 |
108 | if ($frameLength < 6) {
109 | return NULL;
110 | }
111 |
112 | $headers[self::IND_MASKING_KEY] = substr($frame, 2, 4);
113 |
114 | }
115 |
116 | if ($headers[self::IND_MASK]) {
117 | $headers[self::IND_PAYLOAD_OFFSET] += 4;
118 | }
119 |
120 | return $headers;
121 | }
122 |
123 | /**
124 | * Returns the payload from the frame, returns NULL for incomplete frames and FALSE for protocol error
125 | *
126 | * @param string $frame
127 | * @param array|null $headers
128 | *
129 | * @return string|bool|null
130 | */
131 | public static function GetFramePayload(string $frame, ?array $headers = NULL) {
132 |
133 | $headers = ($headers ?? self::GetFrameHeaders($frame));
134 | if ($headers === NULL) {
135 | return NULL;
136 | }
137 |
138 | $frameLength = strlen($frame);
139 | if ($frameLength < $headers[self::IND_PAYLOAD_OFFSET]) { // Frame headers incomplete
140 | return NULL;
141 | }
142 |
143 | $opcode = $headers[self::IND_OPCODE];
144 | switch ($opcode) {
145 | case \PHPWebSockets::OPCODE_CLOSE_CONNECTION:
146 | case \PHPWebSockets::OPCODE_PING:
147 | case \PHPWebSockets::OPCODE_PONG:
148 |
149 | if ($headers[self::IND_LENGTH] === 1 || $headers[self::IND_LENGTH] > 125 || !$headers[self::IND_FIN]) {
150 | return FALSE;
151 | }
152 |
153 | // Fallthrough intended
154 | case \PHPWebSockets::OPCODE_CONTINUE:
155 | case \PHPWebSockets::OPCODE_FRAME_TEXT:
156 | case \PHPWebSockets::OPCODE_FRAME_BINARY:
157 |
158 | if ($frameLength < $headers[self::IND_PAYLOAD_OFFSET] + $headers[self::IND_LENGTH]) {
159 | return NULL;
160 | }
161 |
162 | $payload = substr($frame, $headers[self::IND_PAYLOAD_OFFSET], $headers[self::IND_LENGTH]);
163 | if ($headers[self::IND_MASK]) {
164 | $payload = self::ApplyMask($payload, $headers[self::IND_MASKING_KEY]);
165 | }
166 |
167 | return $payload;
168 | default:
169 | \PHPWebSockets::Log(LogLevel::WARNING, 'Encountered unknown opcode: ' . $opcode);
170 |
171 | return FALSE; // Failure, unknown action
172 | }
173 |
174 | }
175 |
176 | /**
177 | * Frames a message
178 | *
179 | * @param string $data
180 | * @param bool $mask
181 | * @param int $opcode
182 | * @param bool $isFinal
183 | * @param int $rsv
184 | *
185 | * @return string
186 | */
187 | public static function Frame(string $data, bool $mask, int $opcode = \PHPWebSockets::OPCODE_FRAME_TEXT, bool $isFinal = TRUE, int $rsv = 0) : string {
188 |
189 | if ($opcode < 0 || $opcode > 15) {
190 | throw new \RangeException('Invalid opcode, opcode should range between 0 and 15');
191 | }
192 | if (!$isFinal && \PHPWebSockets::IsControlOpcode($opcode)) {
193 | throw new \LogicException('Control frames must be final!');
194 | }
195 | if ($rsv < 0 || $rsv > 7) {
196 | throw new \RangeException('RSV value has to be 0-7');
197 | }
198 |
199 | $byte1 = $opcode;
200 | $byte2 = 0;
201 |
202 | if ($isFinal) {
203 | $byte1 |= self::BYTE1_FIN;
204 | }
205 | if ($rsv) {
206 | $byte1 |= ($rsv << 4);
207 | }
208 |
209 | $dataLength = strlen($data);
210 | $sizeBytes = '';
211 |
212 | if ($dataLength < 126) { // 7 bits
213 |
214 | $byte2 = $dataLength;
215 |
216 | } elseif ($dataLength < 65536) { // 16 bits
217 |
218 | $sizeBytes = pack('n', $dataLength);
219 | $byte2 = 126;
220 |
221 | } else { // 64 bit
222 |
223 | $sizeBytes = pack('J', $dataLength);
224 | $byte2 = 127;
225 |
226 | }
227 |
228 | $maskingKey = '';
229 | if ($mask) {
230 |
231 | $byte2 |= self::BYTE2_MASKED;
232 |
233 | $maskingKey = random_bytes(4);
234 | $data = self::ApplyMask($data, $maskingKey);
235 |
236 | }
237 |
238 | return chr($byte1) . chr($byte2) . $sizeBytes . $maskingKey . $data;
239 | }
240 |
241 | /**
242 | * Applies the mask to the provided payload
243 | *
244 | * @param string $payload
245 | * @param string $maskingKey
246 | *
247 | * @return string
248 | */
249 | public static function ApplyMask(string $payload, string $maskingKey) : string {
250 | return (string) (str_pad('', strlen($payload), $maskingKey) ^ $payload);
251 | }
252 | }
253 |
--------------------------------------------------------------------------------
/tests/ClientTest.php:
--------------------------------------------------------------------------------
1 | _bufferType = substr($arg, 11);
90 |
91 | }
92 |
93 | if ($this->_bufferType === NULL) {
94 | $this->_bufferType = getenv('BUFFERTYPE') ?: NULL;
95 | }
96 |
97 | $this->assertContains($this->_bufferType, static::VALID_BUFFER_TYPES, 'Invalid buffer type, env: ' . implode(', ', getenv()));
98 |
99 | \PHPWebSockets::Log(LogLevel::INFO, 'Using buffer type ' . $this->_bufferType);
100 |
101 | $this->_reportsDir = sys_get_temp_dir() . '/ws_reports';
102 | if (!is_dir($this->_reportsDir)) {
103 | mkdir($this->_reportsDir);
104 | }
105 |
106 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
107 | $serverPort = 9001;
108 | $image = 'crossbario/autobahn-testsuite';
109 | $cmd = 'docker run --rm \
110 | -v "' . realpath(__DIR__ . '/../Resources/Autobahn') . ':/config" \
111 | -v "' . $this->_reportsDir . ':/reports" \
112 | -p ' . $serverPort . ':9001 \
113 | --name ' . escapeshellarg(self::CONTAINER_NAME) . ' \
114 | ' . $image . ' \
115 | wstest -m fuzzingserver -s /config/fuzzingserver.json
116 | ';
117 |
118 | \PHPWebSockets::Log(LogLevel::INFO, 'Pulling image ' . $image);
119 | passthru('docker pull ' . $image);
120 |
121 | $this->_autobahnProcess = proc_open($cmd, $descriptorSpec, $pipes);
122 |
123 | $sleepSec = 5;
124 |
125 | \PHPWebSockets::Log(LogLevel::INFO, 'Sleeping ' . $sleepSec . ' seconds to wait for the fuzzing server to start');
126 |
127 | sleep($sleepSec);
128 |
129 | $serverIP = trim(exec('docker inspect -f "{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}" ' . self::CONTAINER_NAME));
130 | $this->_serverURI = 'tcp://' . $serverIP . ':' . $serverPort;
131 |
132 | $client = $this->_createClient();
133 | $connectResult = $client->connect($this->_serverURI, '/getCaseCount');
134 |
135 | $this->assertTrue($connectResult, 'Unable to connect to address ' . $this->_serverURI . ': ' . $client->getLastError());
136 |
137 | while ($client->isOpen()) {
138 |
139 | foreach ($client->update(NULL) as $key => $value) {
140 |
141 | \PHPWebSockets::Log(LogLevel::DEBUG, 'Got message: ' . $value);
142 |
143 | if ($value instanceof Read && $value->getCode() === Read::C_READ) {
144 |
145 | $msg = $value->getMessage() ?? NULL;
146 | if ($msg === NULL) {
147 |
148 | $stream = $value->getStream();
149 | rewind($stream);
150 | $msg = stream_get_contents($stream);
151 |
152 | }
153 |
154 | $this->_caseCount = json_decode($msg);
155 |
156 | }
157 |
158 | }
159 |
160 | }
161 |
162 | $this->assertGreaterThan(0, $this->_caseCount, 'Unable to get case count from autobahn server!');
163 |
164 | \PHPWebSockets::Log(LogLevel::INFO, 'Will run ' . $this->_caseCount . ' test cases');
165 |
166 | }
167 |
168 | protected function tearDown() : void {
169 |
170 | \PHPWebSockets::Log(LogLevel::INFO, 'Tearing down');
171 | proc_terminate($this->_autobahnProcess);
172 | exec('docker container stop ' . escapeshellarg(self::CONTAINER_NAME));
173 |
174 | }
175 |
176 | public function testClient() : void {
177 |
178 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting tests..');
179 |
180 | for ($i = 0; $i < $this->_caseCount; $i++) {
181 |
182 | $client = $this->_createClient();
183 | $client->connect($this->_serverURI, '/runCase?case=' . ($i + 1) . '&agent=' . $client->getUserAgent());
184 |
185 | while ($client->isOpen()) {
186 |
187 | $updates = $client->update(NULL);
188 | foreach ($updates as $update) {
189 |
190 | if ($update instanceof Read && $update->getCode() === Read::C_READ) {
191 |
192 | $message = $update->getMessage() ?? '';
193 | if ($message === '') {
194 |
195 | $stream = $update->getStream();
196 | if ($stream) {
197 | rewind($stream);
198 | $message = stream_get_contents($stream);
199 | }
200 |
201 | }
202 |
203 | $client->write($message, $update->getOpcode());
204 |
205 | }
206 |
207 | }
208 |
209 | }
210 | }
211 |
212 | \PHPWebSockets::Log(LogLevel::INFO, 'All test cases ran, asking for report update');
213 | $client = $this->_createClient();
214 | $client->connect($this->_serverURI, '/updateReports?agent=' . $client->getUserAgent());
215 |
216 | while ($client->isOpen()) {
217 | foreach ($client->update(NULL) as $key => $value) {
218 | // Nothing, the remote will close it for us
219 | }
220 | }
221 |
222 | \PHPWebSockets::Log(LogLevel::INFO, 'Reports finished, getting results..');
223 | $outputFile = $this->_reportsDir . '/index.json';
224 | $this->assertFileExists($outputFile);
225 |
226 | $testCases = json_decode(file_get_contents($outputFile), TRUE)[$client->getUserAgent()] ?? NULL;
227 | $this->assertNotNull($testCases, 'Unable to get test case results');
228 |
229 | $hasFailures = FALSE;
230 | foreach ($testCases as $case => $data) {
231 |
232 | \PHPWebSockets::Log(LogLevel::INFO, $case . ' => ' . $data['behavior']);
233 | switch ($data['behavior']) {
234 | case 'OK':
235 | case 'NON-STRICT':
236 | case 'INFORMATIONAL':
237 | case 'UNIMPLEMENTED':
238 | break;
239 | default:
240 | $hasFailures = TRUE;
241 | break;
242 | }
243 |
244 | }
245 |
246 | $this->assertFalse($hasFailures, 'One or more test cases failed!');
247 |
248 | \PHPWebSockets::Log(LogLevel::INFO, 'Test success');
249 |
250 | }
251 |
252 | protected function _createClient() : Client {
253 |
254 | $client = new Client();
255 |
256 | if ($this->_bufferType === 'tmpfile') {
257 |
258 | $client->setNewMessageStreamCallback(function (array $headers) {
259 | return tmpfile();
260 | });
261 |
262 | }
263 |
264 | return $client;
265 | }
266 | }
267 |
--------------------------------------------------------------------------------
/Resources/Doxygen/File.doxy:
--------------------------------------------------------------------------------
1 | # Doxyfile 1.8.12
2 |
3 | #---------------------------------------------------------------------------
4 | # Project related configuration options
5 | #---------------------------------------------------------------------------
6 | DOXYFILE_ENCODING = UTF-8
7 | PROJECT_NAME = PHPWebSockets
8 | PROJECT_NUMBER =
9 | PROJECT_BRIEF =
10 | PROJECT_LOGO =
11 | ALLOW_UNICODE_NAMES = NO
12 | OUTPUT_LANGUAGE = English
13 | BRIEF_MEMBER_DESC = YES
14 | REPEAT_BRIEF = YES
15 | ABBREVIATE_BRIEF = "The $name class" \
16 | "The $name widget" \
17 | "The $name file" \
18 | is \
19 | provides \
20 | specifies \
21 | contains \
22 | represents \
23 | a \
24 | an \
25 | the
26 | ALWAYS_DETAILED_SEC = NO
27 | INLINE_INHERITED_MEMB = NO
28 | FULL_PATH_NAMES = YES
29 | STRIP_FROM_PATH =
30 | STRIP_FROM_INC_PATH =
31 | SHORT_NAMES = NO
32 | JAVADOC_AUTOBRIEF = NO
33 | QT_AUTOBRIEF = NO
34 | MULTILINE_CPP_IS_BRIEF = NO
35 | INHERIT_DOCS = YES
36 | SEPARATE_MEMBER_PAGES = NO
37 | TAB_SIZE = 4
38 | ALIASES =
39 | # TCL_SUBST =
40 | OPTIMIZE_OUTPUT_FOR_C = YES
41 | OPTIMIZE_OUTPUT_JAVA = NO
42 | OPTIMIZE_FOR_FORTRAN = NO
43 | OPTIMIZE_OUTPUT_VHDL = NO
44 | EXTENSION_MAPPING =
45 | MARKDOWN_SUPPORT = YES
46 | # TOC_INCLUDE_HEADINGS = 0
47 | AUTOLINK_SUPPORT = YES
48 | BUILTIN_STL_SUPPORT = NO
49 | CPP_CLI_SUPPORT = NO
50 | SIP_SUPPORT = NO
51 | IDL_PROPERTY_SUPPORT = YES
52 | DISTRIBUTE_GROUP_DOC = NO
53 | GROUP_NESTED_COMPOUNDS = NO
54 | SUBGROUPING = YES
55 | INLINE_GROUPED_CLASSES = NO
56 | INLINE_SIMPLE_STRUCTS = NO
57 | TYPEDEF_HIDES_STRUCT = NO
58 | # LOOKUP_CACHE_SIZE = 0
59 | #---------------------------------------------------------------------------
60 | # Build related configuration options
61 | #---------------------------------------------------------------------------
62 | EXTRACT_ALL = YES
63 | EXTRACT_PRIVATE = NO
64 | EXTRACT_PACKAGE = NO
65 | EXTRACT_STATIC = NO
66 | EXTRACT_LOCAL_CLASSES = YES
67 | EXTRACT_LOCAL_METHODS = NO
68 | EXTRACT_ANON_NSPACES = NO
69 | HIDE_UNDOC_MEMBERS = NO
70 | HIDE_UNDOC_CLASSES = NO
71 | HIDE_FRIEND_COMPOUNDS = NO
72 | HIDE_IN_BODY_DOCS = NO
73 | INTERNAL_DOCS = NO
74 | CASE_SENSE_NAMES = NO
75 | HIDE_SCOPE_NAMES = YES
76 | HIDE_COMPOUND_REFERENCE= NO
77 | SHOW_INCLUDE_FILES = YES
78 | SHOW_GROUPED_MEMB_INC = NO
79 | FORCE_LOCAL_INCLUDES = NO
80 | INLINE_INFO = YES
81 | SORT_MEMBER_DOCS = YES
82 | SORT_BRIEF_DOCS = NO
83 | SORT_MEMBERS_CTORS_1ST = NO
84 | SORT_GROUP_NAMES = NO
85 | SORT_BY_SCOPE_NAME = NO
86 | STRICT_PROTO_MATCHING = NO
87 | GENERATE_TODOLIST = YES
88 | GENERATE_TESTLIST = YES
89 | GENERATE_BUGLIST = YES
90 | GENERATE_DEPRECATEDLIST= YES
91 | ENABLED_SECTIONS =
92 | # MAX_INITIALIZER_LINES = 30
93 | SHOW_USED_FILES = YES
94 | SHOW_FILES = YES
95 | SHOW_NAMESPACES = YES
96 | # FILE_VERSION_FILTER =
97 | LAYOUT_FILE =
98 | CITE_BIB_FILES =
99 | #---------------------------------------------------------------------------
100 | # Configuration options related to warning and progress messages
101 | #---------------------------------------------------------------------------
102 | # QUIET = NO
103 | # WARNINGS = YES
104 | # WARN_IF_UNDOCUMENTED = YES
105 | # WARN_IF_DOC_ERROR = YES
106 | # WARN_NO_PARAMDOC = NO
107 | # WARN_AS_ERROR = NO
108 | # WARN_FORMAT = "$file:$line: $text"
109 | # WARN_LOGFILE =
110 | #---------------------------------------------------------------------------
111 | # Configuration options related to the input files
112 | #---------------------------------------------------------------------------
113 | INPUT_ENCODING = UTF-8
114 | FILE_PATTERNS = *.php
115 | RECURSIVE = YES
116 | EXCLUDE =
117 | # EXCLUDE_SYMLINKS = NO
118 | EXCLUDE_PATTERNS =
119 | EXCLUDE_SYMBOLS =
120 | EXAMPLE_PATH =
121 | EXAMPLE_PATTERNS = *
122 | EXAMPLE_RECURSIVE = NO
123 | IMAGE_PATH =
124 | # INPUT_FILTER =
125 | # FILTER_PATTERNS =
126 | # FILTER_SOURCE_FILES = NO
127 | # FILTER_SOURCE_PATTERNS =
128 | USE_MDFILE_AS_MAINPAGE =
129 | #---------------------------------------------------------------------------
130 | # Configuration options related to source browsing
131 | #---------------------------------------------------------------------------
132 | SOURCE_BROWSER = NO
133 | INLINE_SOURCES = NO
134 | STRIP_CODE_COMMENTS = YES
135 | REFERENCED_BY_RELATION = NO
136 | REFERENCES_RELATION = NO
137 | REFERENCES_LINK_SOURCE = YES
138 | SOURCE_TOOLTIPS = YES
139 | # USE_HTAGS = NO
140 | VERBATIM_HEADERS = YES
141 | # CLANG_ASSISTED_PARSING = NO
142 | # CLANG_OPTIONS =
143 | #---------------------------------------------------------------------------
144 | # Configuration options related to the alphabetical class index
145 | #---------------------------------------------------------------------------
146 | ALPHABETICAL_INDEX = YES
147 | COLS_IN_ALPHA_INDEX = 5
148 | IGNORE_PREFIX =
149 | #---------------------------------------------------------------------------
150 | # Configuration options related to the HTML output
151 | #---------------------------------------------------------------------------
152 | # GENERATE_HTML = YES
153 | # HTML_OUTPUT = html
154 | # HTML_FILE_EXTENSION = .html
155 | HTML_HEADER =
156 | HTML_FOOTER =
157 | HTML_STYLESHEET =
158 | HTML_EXTRA_STYLESHEET =
159 | HTML_EXTRA_FILES =
160 | HTML_COLORSTYLE_HUE = 220
161 | HTML_COLORSTYLE_SAT = 100
162 | HTML_COLORSTYLE_GAMMA = 80
163 | HTML_TIMESTAMP = NO
164 | HTML_DYNAMIC_SECTIONS = NO
165 | HTML_INDEX_NUM_ENTRIES = 100
166 | # GENERATE_DOCSET = NO
167 | # DOCSET_FEEDNAME = "Doxygen generated docs"
168 | # DOCSET_BUNDLE_ID = org.doxygen.Project
169 | # DOCSET_PUBLISHER_ID = org.doxygen.Publisher
170 | # DOCSET_PUBLISHER_NAME = Publisher
171 | # GENERATE_HTMLHELP = NO
172 | # CHM_FILE =
173 | # HHC_LOCATION =
174 | # GENERATE_CHI = NO
175 | # CHM_INDEX_ENCODING =
176 | # BINARY_TOC = NO
177 | # TOC_EXPAND = NO
178 | # GENERATE_QHP = NO
179 | # QCH_FILE =
180 | # QHP_NAMESPACE = org.doxygen.Project
181 | # QHP_VIRTUAL_FOLDER = doc
182 | # QHP_CUST_FILTER_NAME =
183 | # QHP_CUST_FILTER_ATTRS =
184 | # QHP_SECT_FILTER_ATTRS =
185 | # QHG_LOCATION =
186 | # GENERATE_ECLIPSEHELP = NO
187 | # ECLIPSE_DOC_ID = org.doxygen.Project
188 | DISABLE_INDEX = NO
189 | GENERATE_TREEVIEW = YES
190 | ENUM_VALUES_PER_LINE = 4
191 | TREEVIEW_WIDTH = 250
192 | EXT_LINKS_IN_WINDOW = NO
193 | FORMULA_FONTSIZE = 10
194 | FORMULA_TRANSPARENT = YES
195 | # USE_MATHJAX = NO
196 | # MATHJAX_FORMAT = HTML-CSS
197 | # MATHJAX_RELPATH = http://cdn.mathjax.org/mathjax/latest
198 | # MATHJAX_EXTENSIONS =
199 | # MATHJAX_CODEFILE =
200 | SEARCHENGINE = YES
201 | # SERVER_BASED_SEARCH = NO
202 | # EXTERNAL_SEARCH = NO
203 | # SEARCHENGINE_URL =
204 | # SEARCHDATA_FILE = searchdata.xml
205 | # EXTERNAL_SEARCH_ID =
206 | # EXTRA_SEARCH_MAPPINGS =
207 | #---------------------------------------------------------------------------
208 | # Configuration options related to the LaTeX output
209 | #---------------------------------------------------------------------------
210 | # GENERATE_LATEX = YES
211 | # LATEX_OUTPUT = latex
212 | # LATEX_CMD_NAME = latex
213 | # MAKEINDEX_CMD_NAME = makeindex
214 | # COMPACT_LATEX = NO
215 | # PAPER_TYPE = a4
216 | # EXTRA_PACKAGES =
217 | # LATEX_HEADER =
218 | # LATEX_FOOTER =
219 | # LATEX_EXTRA_STYLESHEET =
220 | # LATEX_EXTRA_FILES =
221 | # PDF_HYPERLINKS = YES
222 | # USE_PDFLATEX = YES
223 | # LATEX_BATCHMODE = NO
224 | # LATEX_HIDE_INDICES = NO
225 | # LATEX_SOURCE_CODE = NO
226 | # LATEX_BIB_STYLE = plain
227 | # LATEX_TIMESTAMP = NO
228 | #---------------------------------------------------------------------------
229 | # Configuration options related to the RTF output
230 | #---------------------------------------------------------------------------
231 | # GENERATE_RTF = NO
232 | # RTF_OUTPUT = rtf
233 | # COMPACT_RTF = NO
234 | # RTF_HYPERLINKS = NO
235 | # RTF_STYLESHEET_FILE =
236 | # RTF_EXTENSIONS_FILE =
237 | # RTF_SOURCE_CODE = NO
238 | #---------------------------------------------------------------------------
239 | # Configuration options related to the man page output
240 | #---------------------------------------------------------------------------
241 | # GENERATE_MAN = NO
242 | # MAN_OUTPUT = man
243 | # MAN_EXTENSION = .3
244 | # MAN_SUBDIR =
245 | # MAN_LINKS = NO
246 | #---------------------------------------------------------------------------
247 | # Configuration options related to the XML output
248 | #---------------------------------------------------------------------------
249 | # GENERATE_XML = NO
250 | # XML_OUTPUT = xml
251 | # XML_PROGRAMLISTING = YES
252 | #---------------------------------------------------------------------------
253 | # Configuration options related to the DOCBOOK output
254 | #---------------------------------------------------------------------------
255 | # GENERATE_DOCBOOK = NO
256 | # DOCBOOK_OUTPUT = docbook
257 | # DOCBOOK_PROGRAMLISTING = NO
258 | #---------------------------------------------------------------------------
259 | # Configuration options for the AutoGen Definitions output
260 | #---------------------------------------------------------------------------
261 | # GENERATE_AUTOGEN_DEF = NO
262 | #---------------------------------------------------------------------------
263 | # Configuration options related to the Perl module output
264 | #---------------------------------------------------------------------------
265 | # GENERATE_PERLMOD = NO
266 | # PERLMOD_LATEX = NO
267 | # PERLMOD_PRETTY = YES
268 | # PERLMOD_MAKEVAR_PREFIX =
269 | #---------------------------------------------------------------------------
270 | # Configuration options related to the preprocessor
271 | #---------------------------------------------------------------------------
272 | ENABLE_PREPROCESSING = YES
273 | MACRO_EXPANSION = NO
274 | EXPAND_ONLY_PREDEF = NO
275 | SEARCH_INCLUDES = YES
276 | INCLUDE_PATH =
277 | INCLUDE_FILE_PATTERNS =
278 | PREDEFINED =
279 | EXPAND_AS_DEFINED =
280 | SKIP_FUNCTION_MACROS = YES
281 | #---------------------------------------------------------------------------
282 | # Configuration options related to external references
283 | #---------------------------------------------------------------------------
284 | # TAGFILES =
285 | # GENERATE_TAGFILE =
286 | ALLEXTERNALS = NO
287 | EXTERNAL_GROUPS = YES
288 | EXTERNAL_PAGES = YES
289 | # PERL_PATH = /usr/bin/perl
290 | #---------------------------------------------------------------------------
291 | # Configuration options related to the dot tool
292 | #---------------------------------------------------------------------------
293 | CLASS_DIAGRAMS = YES
294 | # MSCGEN_PATH =
295 | # DIA_PATH =
296 | HIDE_UNDOC_RELATIONS = YES
297 | # HAVE_DOT = NO
298 | # DOT_NUM_THREADS = 0
299 | DOT_FONTNAME = Helvetica
300 | DOT_FONTSIZE = 10
301 | # DOT_FONTPATH =
302 | CLASS_GRAPH = YES
303 | COLLABORATION_GRAPH = YES
304 | GROUP_GRAPHS = YES
305 | UML_LOOK = NO
306 | UML_LIMIT_NUM_FIELDS = 10
307 | TEMPLATE_RELATIONS = NO
308 | INCLUDE_GRAPH = YES
309 | INCLUDED_BY_GRAPH = YES
310 | CALL_GRAPH = NO
311 | CALLER_GRAPH = NO
312 | GRAPHICAL_HIERARCHY = YES
313 | DIRECTORY_GRAPH = YES
314 | DOT_IMAGE_FORMAT = png
315 | INTERACTIVE_SVG = NO
316 | # DOT_PATH =
317 | DOTFILE_DIRS =
318 | MSCFILE_DIRS =
319 | DIAFILE_DIRS =
320 | PLANTUML_JAR_PATH =
321 | # PLANTUML_INCLUDE_PATH =
322 | DOT_GRAPH_MAX_NODES = 50
323 | MAX_DOT_GRAPH_DEPTH = 0
324 | DOT_TRANSPARENT = NO
325 | # DOT_MULTI_TARGETS = NO
326 | GENERATE_LEGEND = YES
327 | # DOT_CLEANUP = YES
328 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Client.php:
--------------------------------------------------------------------------------
1 | setLogger($logger);
106 | }
107 |
108 | }
109 |
110 | /**
111 | * Connects to the provided resource
112 | *
113 | * @param resource $resource
114 | * @param string $path
115 | *
116 | * @return bool
117 | */
118 | public function connectToResource($resource, string $path = '/') : bool {
119 |
120 | if (!is_resource($resource)) {
121 | throw new \InvalidArgumentException('Argument 1 is not a resource!');
122 | }
123 |
124 | if ($this->isOpen()) {
125 | throw new \LogicException('The connection is already open!');
126 | }
127 |
128 | $this->_address = @stream_get_meta_data($resource)['uri'] ?? NULL;
129 | $this->_stream = $resource;
130 | $this->_path = $path;
131 |
132 | $this->_afterOpen();
133 |
134 | return TRUE;
135 | }
136 |
137 | /**
138 | * Attempts to connect to a websocket server
139 | *
140 | * @param string $address
141 | * @param string $path
142 | * @param array $streamContext
143 | * @param bool $async
144 | *
145 | * @return bool
146 | */
147 | public function connect(string $address, string $path = '/', array $streamContext = [], bool $async = FALSE) : bool {
148 |
149 | if ($this->isOpen()) {
150 | throw new \LogicException('The connection is already open!');
151 | }
152 |
153 | $this->_isConnectingAsync = $async;
154 |
155 | $flags = ($async ? STREAM_CLIENT_ASYNC_CONNECT : STREAM_CLIENT_CONNECT);
156 |
157 | $this->_stream = @stream_socket_client($address, $this->_streamLastErrorCode, $this->_streamLastError, $this->getConnectTimeout(), $flags, stream_context_create($streamContext));
158 | if ($this->_stream === FALSE) {
159 | return FALSE;
160 | }
161 |
162 | $this->_address = $address;
163 | $this->_path = $path;
164 |
165 | $this->_afterOpen();
166 |
167 | return TRUE;
168 | }
169 |
170 | /**
171 | * {@inheritdoc}
172 | */
173 | protected function _afterOpen() : void {
174 |
175 | parent::_afterOpen();
176 |
177 | $this->_resourceIndex = (int) $this->getStream();
178 | $addressInfo = parse_url($this->getAddress());
179 |
180 | $host = $addressInfo['host'] . (isset($addressInfo['port']) ? ':' . $addressInfo['port'] : '');
181 |
182 | $headerParts = [
183 | 'GET ' . $this->getPath() . ' HTTP/1.1',
184 | 'Host: ' . $host,
185 | 'User-Agent: ' . $this->getUserAgent(),
186 | 'Upgrade: websocket',
187 | 'Connection: Upgrade',
188 | 'Sec-WebSocket-Key: ' . base64_encode(\PHPWebSockets::RandomString(16)),
189 | 'Sec-WebSocket-Version: 13',
190 | ];
191 |
192 | $this->writeRaw(implode("\r\n", $headerParts) . "\r\n\r\n", FALSE);
193 |
194 | }
195 |
196 | /**
197 | * Returns the code of the last error that occurred
198 | *
199 | * @return int|null
200 | */
201 | public function getLastErrorCode() : ?int {
202 | return $this->_streamLastErrorCode;
203 | }
204 |
205 | /**
206 | * Returns the last error that occurred
207 | *
208 | * @return string|null
209 | */
210 | public function getLastError() : ?string {
211 | return $this->_streamLastError;
212 | }
213 |
214 | /**
215 | * Checks for updates for this client
216 | *
217 | * @param float|null $timeout The amount of seconds to wait for updates, setting this value to NULL causes this function to block indefinitely until there is an update
218 | *
219 | * @return \Generator|\PHPWebSockets\AUpdate[]
220 | */
221 | public function update(?float $timeout) : \Generator {
222 | yield from \PHPWebSockets::MultiUpdate([$this], $timeout);
223 | }
224 |
225 | /**
226 | * @return \Generator|\PHPWebSockets\AUpdate[]
227 | */
228 | public function handleRead() : \Generator {
229 |
230 | $this->_log(LogLevel::DEBUG, __METHOD__);
231 |
232 | $readRate = $this->getReadRate();
233 | $newData = @fread($this->getStream(), min($this->_currentFrameRemainingBytes ?? $readRate, $readRate));
234 | if ($newData === FALSE) {
235 |
236 | $errUpdate = new Update\Error(Update\Error::C_READ, $this);
237 | $errUpdate->setAdditionalInfo(error_get_last()['message'] ?? '');
238 |
239 | yield $errUpdate;
240 |
241 | return;
242 | }
243 |
244 | if (strlen($newData) === 0) {
245 |
246 | if (!feof($this->getStream())) {
247 | $this->_log(LogLevel::DEBUG, 'Read length of 0, ignoring');
248 | return;
249 | }
250 |
251 | $this->_log(LogLevel::DEBUG, 'FEOF returns true and no more bytes to read, socket is closed');
252 |
253 | $this->_isClosed = TRUE;
254 |
255 | if ($this->_remoteSentDisconnect && $this->_weSentDisconnect) {
256 | yield new Update\Read(Update\Read::C_SOCK_DISCONNECT, $this);
257 | } elseif ($this->_isConnectingAsync) {
258 | yield new Update\Error(Update\Error::C_ASYNC_CONNECT_FAILED, $this);
259 | } else {
260 | yield new Update\Error(Update\Error::C_READ_UNEXPECTED_DISCONNECT, $this);
261 | }
262 |
263 | $this->close();
264 |
265 | return;
266 | }
267 |
268 | // If we've received data we can be sure we're connected
269 | $this->_isConnectingAsync = FALSE;
270 |
271 | $handshakeAccepted = $this->handshakeAccepted();
272 | if (!$handshakeAccepted) {
273 |
274 | $headersEnd = strpos($newData, "\r\n\r\n");
275 | if ($headersEnd === FALSE) {
276 |
277 | $this->_log(LogLevel::DEBUG, 'Handshake data didn\'t finished yet, waiting..');
278 |
279 | if ($this->_readBuffer === NULL) {
280 | $this->_readBuffer = '';
281 | }
282 |
283 | $this->_readBuffer .= $newData;
284 |
285 | if (strlen($this->_readBuffer) > $this->getMaxHandshakeLength()) {
286 |
287 | yield new Update\Error(Update\Error::C_READ_HANDSHAKE_TO_LARGE, $this);
288 | $this->close();
289 |
290 | }
291 |
292 | return; // Still waiting for headers
293 | }
294 |
295 | if ($this->_readBuffer !== NULL) {
296 |
297 | $newData = $this->_readBuffer . $newData;
298 | $this->_readBuffer = NULL;
299 |
300 | }
301 |
302 | $rawHandshake = substr($newData, 0, $headersEnd);
303 |
304 | if (strlen($newData) > strlen($rawHandshake)) { // Place all data that came after the header back into the buffer
305 | $newData = substr($newData, $headersEnd + 4);
306 | }
307 |
308 | $this->_headers = \PHPWebSockets::ParseHTTPHeaders($rawHandshake);
309 | if (($this->_headers['status-code'] ?? NULL) === 101) {
310 |
311 | $this->_handshakeAccepted = TRUE;
312 | $this->_hasHandshake = TRUE;
313 |
314 | yield new Update\Read(Update\Read::C_CONNECTION_ACCEPTED, $this);
315 | } else {
316 |
317 | $this->close();
318 |
319 | yield new Update\Read(Update\Read::C_CONNECTION_DENIED, $this);
320 |
321 | }
322 |
323 | $handshakeAccepted = $this->handshakeAccepted();
324 |
325 | }
326 |
327 | if ($handshakeAccepted) {
328 | yield from $this->_handlePacket($newData);
329 | }
330 |
331 | }
332 |
333 | /**
334 | * Sets that we should close the connection after all our writes have finished
335 | *
336 | * @return bool
337 | */
338 | public function handshakeAccepted() : bool {
339 | return $this->_handshakeAccepted;
340 | }
341 |
342 | /**
343 | * Returns the user agent string that is reported to the server that we are connecting to, if set to NULL the default value is used
344 | *
345 | * @param string|null $userAgent
346 | *
347 | * @return void
348 | */
349 | public function setUserAgent(?string $userAgent) : void {
350 | $this->_userAgent = $userAgent;
351 | }
352 |
353 | /**
354 | * Returns the user agent string that is reported to the server that we are connecting to
355 | *
356 | * @return string
357 | */
358 | public function getUserAgent() : string {
359 | return $this->_userAgent ?? 'PHPWebSockets/' . \PHPWebSockets::Version();
360 | }
361 |
362 | /**
363 | * If we should send our frames masked
364 | *
365 | * Note: Setting this to FALSE is officially not supported by the websocket RFC, but can improve performance when communicating with servers that support this
366 | *
367 | * @see https://tools.ietf.org/html/rfc6455#section-5.3
368 | *
369 | * @param bool $mask
370 | *
371 | * @return void
372 | */
373 | public function setMasksPayload(bool $mask) : void {
374 | $this->_shouldMask = $mask;
375 | }
376 |
377 | /**
378 | * Returns if the frame we are writing should be masked
379 | *
380 | * @return bool
381 | */
382 | protected function _shouldMask() : bool {
383 | return $this->_shouldMask;
384 | }
385 |
386 | /**
387 | * Returns the timeout in seconds before the connection attempt stops
388 | *
389 | * @return float
390 | */
391 | public function getConnectTimeout() : float {
392 | return (float) (ini_get('default_socket_timeout') ?: 1.0);
393 | }
394 |
395 | /**
396 | * Returns the headers set during the http request
397 | *
398 | * @return array
399 | */
400 | public function getHeaders() : array {
401 | return $this->_headers;
402 | }
403 |
404 | /**
405 | * Returns the address that we connected to
406 | *
407 | * @return string|null
408 | */
409 | public function getAddress() : ?string {
410 | return $this->_address;
411 | }
412 |
413 | /**
414 | * Returns the path send in the HTTP request
415 | *
416 | * @return string|null
417 | */
418 | public function getPath() : ?string {
419 | return $this->_path;
420 | }
421 |
422 | public function __toString() {
423 |
424 | $tag = $this->getTag();
425 |
426 | return 'WSClient #' . $this->_resourceIndex . ($tag === NULL ? '' : ' (Tag: ' . $tag . ')');
427 | }
428 | }
429 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Server/Connection.php:
--------------------------------------------------------------------------------
1 | _remoteIP = parse_url($streamName, PHP_URL_HOST);
94 | $this->_server = $server;
95 | $this->_stream = $stream;
96 | $this->_index = $index;
97 |
98 | // Inherit the logger from the server
99 | $serverLogger = $server->getLogger();
100 | if ($serverLogger && $serverLogger !== \PHPWebSockets::GetLogger()) {
101 | $this->setLogger($serverLogger);
102 | }
103 |
104 | $this->_resourceIndex = (int) $this->_stream;
105 |
106 | // The crypto enable HAS to happen before disabling blocking mode
107 | if ($server->usesCrypto()) {
108 | stream_socket_enable_crypto($this->_stream, TRUE, STREAM_CRYPTO_METHOD_TLS_SERVER);
109 | }
110 |
111 | $this->_afterOpen();
112 |
113 | }
114 |
115 | /**
116 | * Attempts to read from our connection
117 | *
118 | * @return \Generator|\PHPWebSockets\AUpdate[]
119 | */
120 | public function handleRead() : \Generator {
121 |
122 | $this->_log(LogLevel::DEBUG, __METHOD__);
123 |
124 | $readRate = $this->getReadRate();
125 | $newData = @fread($this->getStream(), min($this->_currentFrameRemainingBytes ?? $readRate, $readRate));
126 | if ($newData === FALSE) {
127 |
128 | $errUpdate = new Update\Error(Update\Error::C_READ, $this);
129 | $errUpdate->setAdditionalInfo(error_get_last()['message'] ?? '');
130 | yield $errUpdate;
131 |
132 | return;
133 | }
134 |
135 | if (strlen($newData) === 0) {
136 |
137 | if (!feof($this->getStream())) {
138 | $this->_log(LogLevel::DEBUG, 'Read length of 0, ignoring');
139 | return;
140 | }
141 |
142 | $this->_log(LogLevel::DEBUG, 'FEOF returns true and no more bytes to read, socket is closed');
143 |
144 | $this->_isClosed = TRUE;
145 |
146 | if (!$this->hasHandshake()) {
147 | yield new Update\Error(Update\Error::C_READ_DISCONNECT_DURING_HANDSHAKE, $this);
148 | } elseif ($this->_remoteSentDisconnect && $this->_weSentDisconnect) {
149 | yield new Update\Read(Update\Read::C_SOCK_DISCONNECT, $this);
150 | } else {
151 | yield new Update\Error(Update\Error::C_READ_UNEXPECTED_DISCONNECT, $this);
152 | }
153 |
154 | $this->close();
155 |
156 | return;
157 | }
158 |
159 | $hasHandshake = $this->hasHandshake();
160 | if (!$hasHandshake) {
161 |
162 | $headersEnd = strpos($newData, "\r\n\r\n");
163 | if ($headersEnd === FALSE) {
164 |
165 | $this->_log(LogLevel::DEBUG, 'Handshake data hasn\'t finished yet, waiting..');
166 |
167 | if ($this->_readBuffer === NULL) {
168 | $this->_readBuffer = '';
169 | }
170 |
171 | $this->_readBuffer .= $newData;
172 |
173 | if (strlen($this->_readBuffer) > $this->getMaxHandshakeLength()) {
174 |
175 | $this->writeRaw($this->_server->getErrorPageForCode(431), FALSE); // Request Header Fields Too Large
176 | $this->setCloseAfterWrite();
177 |
178 | yield new Update\Error(Update\Error::C_READ_HANDSHAKE_TO_LARGE, $this);
179 |
180 | }
181 |
182 | return; // Still waiting for headers
183 | }
184 |
185 | if ($this->_readBuffer !== NULL) {
186 |
187 | $newData = $this->_readBuffer . $newData;
188 | $this->_readBuffer = NULL;
189 |
190 | }
191 |
192 | $rawHandshake = substr($newData, 0, $headersEnd);
193 |
194 | if (strlen($newData) > strlen($rawHandshake)) {
195 | $newData = substr($newData, $headersEnd + 4);
196 | }
197 |
198 | $responseCode = 0;
199 | if ($this->_doHandshake($rawHandshake, $responseCode)) {
200 | yield new Update\Read(Update\Read::C_NEW_CONNECTION, $this);
201 | } else {
202 |
203 | $this->writeRaw($this->_server->getErrorPageForCode($responseCode), FALSE);
204 | $this->setCloseAfterWrite();
205 |
206 | yield new Update\Error(Update\Error::C_READ_HANDSHAKE_FAILURE, $this);
207 |
208 | }
209 |
210 | $hasHandshake = $this->hasHandshake();
211 |
212 | }
213 |
214 | if ($hasHandshake) {
215 | yield from $this->_handlePacket($newData);
216 | }
217 |
218 | }
219 |
220 | /**
221 | * {@inheritdoc}
222 | */
223 | public function beforeStreamSelect() : \Generator {
224 |
225 | yield from parent::beforeStreamSelect();
226 |
227 | if (!$this->isAccepted() && $this->hasHandshake() && $this->getOpenedTimestamp() + $this->getAcceptTimeout() < time()) {
228 |
229 | yield new Update\Error(Update\Error::C_ACCEPT_TIMEOUT_PASSED, $this);
230 | $this->deny(504); // Gateway Timeout
231 |
232 | }
233 |
234 | }
235 |
236 | /**
237 | * Handles the handshake from the client and returns if the handshake was valid
238 | *
239 | * @param string $rawHandshake
240 | * @param int &$responseCode
241 | *
242 | * @return bool
243 | */
244 | protected function _doHandshake(string $rawHandshake, int &$responseCode) : bool {
245 |
246 | $headers = \PHPWebSockets::ParseHTTPHeaders($rawHandshake);
247 |
248 | $responseCode = 101;
249 | if (!isset($headers['get'])) {
250 | $responseCode = 405; // Method Not Allowed
251 | } elseif (!isset($headers['host'])) {
252 | $responseCode = 400; // Bad Request
253 | } elseif (!isset($headers['upgrade']) || strtolower($headers['upgrade']) !== 'websocket') {
254 | $responseCode = 400; // Bad Request
255 | } elseif (!isset($headers['connection']) || strpos(strtolower($headers['connection']), 'upgrade') === FALSE) {
256 | $responseCode = 400; // Bad Request
257 | } elseif (!isset($headers['sec-websocket-key'])) {
258 | $responseCode = 400; // Bad Request
259 | } elseif (!isset($headers['sec-websocket-version']) || intval($headers['sec-websocket-version']) !== 13) {
260 | $responseCode = 426; // Upgrade Required
261 | }
262 |
263 | $this->_headers = $headers;
264 |
265 | $this->_parseHeaders();
266 |
267 | if ($responseCode >= 300) {
268 | return FALSE;
269 | }
270 |
271 | $this->_hasHandshake = TRUE;
272 |
273 | $hash = sha1($headers['sec-websocket-key'] . \PHPWebSockets::WEBSOCKET_GUID);
274 | $this->_rawToken = '';
275 | for ($i = 0; $i < 20; $i++) {
276 | $this->_rawToken .= chr(hexdec(substr($hash, $i * 2, 2)));
277 | }
278 |
279 | return TRUE;
280 | }
281 |
282 | /**
283 | * @return void
284 | */
285 | protected function _parseHeaders() : void {
286 |
287 | if ($this->_server && $this->_server->getTrustForwardedHeaders()) {
288 |
289 | $headers = $this->getHeaders();
290 |
291 | $realIP = $headers['x-real-ip'] ?? NULL;
292 | if ($realIP) {
293 | $this->_remoteIP = $realIP;
294 | } else {
295 |
296 | /*
297 | * X-Forwarded-For is a comma separated list of proxies, the first entry is the client's IP
298 | */
299 |
300 | $forwardedForParts = explode(',', $headers['x-forwarded-for'] ?? '');
301 | $forwardedFor = reset($forwardedForParts);
302 | if ($forwardedFor) {
303 | $this->_remoteIP = $forwardedFor;
304 | }
305 |
306 | }
307 |
308 | }
309 |
310 | }
311 |
312 | /**
313 | * Accepts the connection
314 | *
315 | * @param string|null $protocol The accepted protocol
316 | * @param string[] $additionalHeaders Additional headers to send in the response
317 | *
318 | * @return void
319 | */
320 | public function accept(?string $protocol = NULL, array $additionalHeaders = []) : void {
321 |
322 | if ($this->isAccepted()) {
323 | throw new \LogicException('Connection has already been accepted!');
324 | }
325 |
326 | $headers = [
327 | 'Server: ' . $this->_server->getServerIdentifier(),
328 | 'Upgrade: websocket',
329 | 'Connection: Upgrade',
330 | 'Sec-WebSocket-Accept: ' . base64_encode($this->_rawToken),
331 | ];
332 |
333 | if ($protocol !== NULL) {
334 | $headers[] = 'Sec-WebSocket-Protocol: ' . $protocol;
335 | }
336 |
337 | foreach ($additionalHeaders as $header) {
338 | $headers[] = $header;
339 | }
340 |
341 | $this->writeRaw('HTTP/1.1 101 ' . \PHPWebSockets::GetStringForStatusCode(101) . "\r\n" . implode("\r\n", $headers) . "\r\n\r\n", FALSE);
342 |
343 | $this->_accepted = TRUE;
344 |
345 | }
346 |
347 | /**
348 | * Denies the websocket connection, a HTTP error code has to be provided indicating what went wrong
349 | *
350 | * @param int $errCode
351 | *
352 | * @return void
353 | */
354 | public function deny(int $errCode) : void {
355 |
356 | if ($this->isAccepted()) {
357 | throw new \LogicException('Connection has already been accepted!');
358 | }
359 |
360 | $this->writeRaw('HTTP/1.1 ' . $errCode . ' ' . \PHPWebSockets::GetStringForStatusCode($errCode) . "\r\nServer: " . $this->_server->getServerIdentifier() . "\r\n\r\n", FALSE);
361 | $this->setCloseAfterWrite();
362 |
363 | }
364 |
365 | /**
366 | * Detaches this connection from its server
367 | *
368 | * @return void
369 | */
370 | public function detach() : void {
371 |
372 | if (!$this->isAccepted()) {
373 | throw new \LogicException('Connections can only be detached after it has been accepted');
374 | }
375 |
376 | $this->_server->removeConnection($this, FALSE);
377 | $this->_server = NULL;
378 |
379 | }
380 |
381 | /**
382 | * Sets the time in seconds in which the client has to send its handshake
383 | *
384 | * @param float $timeout
385 | *
386 | * @return void
387 | */
388 | public function setAcceptTimeout(float $timeout) : void {
389 | $this->_acceptTimeout = $timeout;
390 | }
391 |
392 | /**
393 | * Returns the time in seconds in which the client has to send its handshake
394 | *
395 | * @return float
396 | */
397 | public function getAcceptTimeout() : float {
398 | return $this->_acceptTimeout;
399 | }
400 |
401 | /**
402 | * Returns if the websocket connection has been accepted
403 | *
404 | * @return bool
405 | */
406 | public function isAccepted() : bool {
407 | return $this->_accepted;
408 | }
409 |
410 | /**
411 | * Returns the related websocket server
412 | *
413 | * @return \PHPWebSockets\Server|null
414 | */
415 | public function getServer() : ?Server {
416 | return $this->_server;
417 | }
418 |
419 | /**
420 | * Returns if the frame we are writing should be masked
421 | *
422 | * @return bool
423 | */
424 | protected function _shouldMask() : bool {
425 | return FALSE;
426 | }
427 |
428 | /**
429 | * Returns the headers set during the http request, this can be empty if called before the handshake has been completed
430 | *
431 | * @return array
432 | */
433 | public function getHeaders() : array {
434 | return $this->_headers ?: [];
435 | }
436 |
437 | /**
438 | * Returns the remote IP address of the client
439 | *
440 | * @return string|null
441 | */
442 | public function getRemoteIP() : ?string {
443 | return $this->_remoteIP;
444 | }
445 |
446 | /**
447 | * Returns the index for this connection
448 | *
449 | * @return int
450 | */
451 | public function getIndex() : int {
452 | return $this->_index;
453 | }
454 |
455 | /**
456 | * {@inheritdoc}
457 | */
458 | public function close() : void {
459 |
460 | parent::close();
461 |
462 | if ($this->_shouldReportClose && !$this->isAccepted()) {
463 |
464 | /*
465 | * Don't report close if we've never been accepted
466 | */
467 |
468 | $this->_shouldReportClose = FALSE;
469 |
470 | }
471 |
472 | if ($this->_server !== NULL) {
473 |
474 | if (!$this->_shouldReportClose) {
475 |
476 | $this->_log(LogLevel::DEBUG, 'Not reporting, remove now');
477 | $this->_server->removeConnection($this);
478 |
479 | } else {
480 | $this->_log(LogLevel::DEBUG, 'Going to report later, not removing');
481 | }
482 |
483 | } else {
484 | $this->_log(LogLevel::DEBUG, 'No server, not removing');
485 | }
486 |
487 | }
488 |
489 | /**
490 | * {@inheritdoc}
491 | */
492 | protected function _afterReportClose() : void {
493 |
494 | if ($this->_server !== NULL) {
495 |
496 | $this->_log(LogLevel::DEBUG, 'We reported close, removing from server');
497 | $this->_server->removeConnection($this);
498 |
499 | }
500 |
501 | }
502 |
503 | public function __toString() {
504 |
505 | $remoteIP = $this->getRemoteIP();
506 | $tag = $this->getTag();
507 |
508 | return 'WSConnection #' . $this->_resourceIndex . ($remoteIP ? ' => ' . $remoteIP : '') . ($tag === NULL ? '' : ' (Tag: ' . $tag . ')') . ($this->_server !== NULL ? ' @ ' . $this->_server : '');
509 | }
510 | }
511 |
--------------------------------------------------------------------------------
/tests/UpdatesWrapperTest.php:
--------------------------------------------------------------------------------
1 | _updatesWrapper = new \PHPWebSockets\UpdatesWrapper();
65 | $this->_updatesWrapper->setDisconnectHandler(function (\PHPWebSockets\AConnection $connection, bool $wasClean, int $code, string $reason) {
66 |
67 | /*
68 | * If a connection closes, check if we have the connection and remove it
69 | */
70 |
71 | \PHPWebSockets::Log(LogLevel::INFO, 'Disconnect ' . $connection);
72 |
73 | $this->assertContains($connection, $this->_connectionList);
74 |
75 | if ($connection instanceof \PHPWebSockets\Server\Connection) {
76 | $this->assertTrue($connection->getServer()->hasConnection($connection), 'Server doesn\'t have a reference to its connection!');
77 | }
78 |
79 | unset($this->_connectionList[$connection->getResourceIndex()]);
80 |
81 | });
82 | $this->_updatesWrapper->setClientConnectedHandler(function (\PHPWebSockets\Client $client) {
83 |
84 | /*
85 | * If a client has connected check if it doesn't already exist and add it to the list
86 | */
87 |
88 | \PHPWebSockets::Log(LogLevel::INFO, 'Connect ' . $client);
89 |
90 | $this->assertNotContains($client, $this->_connectionList);
91 |
92 | $index = $client->getResourceIndex();
93 |
94 | $this->assertIsInt($index);
95 | $this->assertArrayNotHasKey($index, $this->_connectionList);
96 |
97 | $this->_connectionList[$index] = $client;
98 |
99 | });
100 | $this->_updatesWrapper->setNewConnectionHandler(function (\PHPWebSockets\Server\Connection $connection) {
101 |
102 | /*
103 | * If a client has connected to the server, check if it doesn't already exist and add it to the list
104 | */
105 |
106 | \PHPWebSockets::Log(LogLevel::INFO, 'Connect ' . $connection);
107 |
108 | $this->assertNotContains($connection, $this->_connectionList);
109 |
110 | $index = $connection->getResourceIndex();
111 |
112 | $this->assertIsInt($index);
113 | $this->assertArrayNotHasKey($index, $this->_connectionList);
114 |
115 | if ($this->_refuseNextConnection) {
116 |
117 | \PHPWebSockets::Log(LogLevel::INFO, 'Denying ' . $connection);
118 |
119 | $connection->deny(500);
120 |
121 | $this->_refuseNextConnection = FALSE;
122 |
123 | } else {
124 |
125 | $connection->accept();
126 |
127 | $this->_connectionList[$index] = $connection;
128 |
129 | }
130 |
131 | });
132 | $this->_updatesWrapper->setLastContactHandler(function (\PHPWebSockets\AConnection $connection) {
133 |
134 | /*
135 | * Check if we have the connection
136 | */
137 |
138 | \PHPWebSockets::Log(LogLevel::INFO, 'Got contact ' . $connection);
139 |
140 | $this->assertContains($connection, $this->_connectionList);
141 |
142 | });
143 | $this->_updatesWrapper->setMessageHandler(function (\PHPWebSockets\AConnection $connection, string $message, int $opcode) {
144 |
145 | /*
146 | * Check if we have the connection and echo the received message
147 | */
148 |
149 | \PHPWebSockets::Log(LogLevel::INFO, 'Got message ' . $connection);
150 |
151 | $this->assertContains($connection, $this->_connectionList);
152 |
153 | $connection->write($message, $opcode);
154 |
155 | });
156 | $this->_updatesWrapper->setErrorHandler(function (\PHPWebSockets\AConnection $connection, int $code) {
157 |
158 | \PHPWebSockets::Log(LogLevel::INFO, 'Got error ' . $code . ' from ' . $connection);
159 |
160 | });
161 |
162 | $this->_wsServer = new \PHPWebSockets\Server(self::ADDRESS);
163 |
164 | }
165 |
166 | protected function tearDown() : void {
167 |
168 | $this->_wsServer->close();
169 |
170 | }
171 |
172 | public function testWrapperNormal() {
173 |
174 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
175 |
176 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
177 |
178 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
179 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --message-count=5', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
180 |
181 | while (($status = proc_get_status($clientProcess))['running'] ?? FALSE) {
182 |
183 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
184 |
185 | }
186 |
187 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
188 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
189 | $this->assertEmpty($this->_connectionList);
190 |
191 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
192 |
193 | }
194 |
195 | public function testWrapperAsyncClient() {
196 |
197 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
198 |
199 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
200 |
201 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
202 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --message-count=5 --async', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
203 |
204 | while (($status = proc_get_status($clientProcess))['running'] ?? FALSE) {
205 |
206 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
207 |
208 | }
209 |
210 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
211 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
212 | $this->assertEmpty($this->_connectionList);
213 |
214 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
215 |
216 | }
217 |
218 | public function testWrapperClientDisappeared() {
219 |
220 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
221 |
222 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
223 |
224 | $dieAt = microtime(TRUE) + 3.0;
225 | $runUntil = $dieAt + 4.0;
226 |
227 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
228 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --die-at=' . escapeshellarg((string) $dieAt), $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
229 |
230 | $status = NULL;
231 |
232 | while (microtime(TRUE) <= $runUntil) {
233 |
234 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
235 |
236 | if ($clientProcess !== NULL) {
237 |
238 | $status = proc_get_status($clientProcess);
239 | if (!$status['running']) {
240 |
241 | \PHPWebSockets::Log(LogLevel::INFO, 'Client disappeared');
242 | $clientProcess = NULL;
243 |
244 | }
245 |
246 | }
247 |
248 | }
249 |
250 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
251 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
252 | $this->assertEmpty($this->_connectionList);
253 |
254 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
255 |
256 | }
257 |
258 | public function testWrapperServerClose() {
259 |
260 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
261 |
262 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
263 |
264 | $closeAt = microtime(TRUE) + 3.0;
265 | $runUntil = $closeAt + 4.0;
266 |
267 | $didClose = FALSE;
268 |
269 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
270 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --message-count=5', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
271 |
272 | while (microtime(TRUE) <= $runUntil) {
273 |
274 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
275 |
276 | if (!$didClose) {
277 | $this->assertTrue(proc_get_status($clientProcess)['running'] ?? FALSE);
278 | }
279 |
280 | if (microtime(TRUE) >= $closeAt && !$didClose) {
281 |
282 | $connections = $this->_wsServer->getConnections(FALSE);
283 |
284 | $this->assertNotEmpty($connections);
285 |
286 | \PHPWebSockets::Log(LogLevel::INFO, 'Closing connection');
287 |
288 | $connection = reset($connections);
289 | $connection->close();
290 |
291 | $didClose = TRUE;
292 |
293 | }
294 |
295 | }
296 |
297 | $status = proc_get_status($clientProcess);
298 |
299 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
300 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
301 | $this->assertEmpty($this->_connectionList);
302 |
303 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
304 |
305 | }
306 |
307 | public function testWrapperClientClose() {
308 |
309 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
310 |
311 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
312 |
313 | $closeAt = microtime(TRUE) + 3.0;
314 |
315 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
316 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --close-at=' . $closeAt . ' --message-count=5', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
317 |
318 | while (TRUE) {
319 |
320 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
321 |
322 | $status = proc_get_status($clientProcess);
323 | if (!($status['running'] ?? FALSE)) {
324 | break;
325 | }
326 |
327 | }
328 |
329 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
330 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
331 | $this->assertEmpty($this->_connectionList);
332 |
333 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
334 |
335 | }
336 |
337 | public function testWrapperClientRefuse() {
338 |
339 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
340 |
341 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
342 |
343 | $this->_refuseNextConnection = TRUE;
344 |
345 | $runUntil = microtime(TRUE) + 8.0;
346 |
347 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
348 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --message-count=1', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
349 |
350 | $status = NULL;
351 |
352 | while (microtime(TRUE) <= $runUntil) {
353 |
354 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
355 |
356 | if ($clientProcess !== NULL) {
357 |
358 | $status = proc_get_status($clientProcess);
359 | if (!$status['running']) {
360 |
361 | \PHPWebSockets::Log(LogLevel::INFO, 'Client disappeared');
362 | $clientProcess = NULL;
363 |
364 | }
365 |
366 | }
367 |
368 | }
369 |
370 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
371 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
372 | $this->assertEmpty($this->_connectionList);
373 |
374 | $this->_refuseNextConnection = FALSE;
375 |
376 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
377 |
378 | }
379 |
380 | public function testWrapperAsyncClientTCPRefuse() {
381 |
382 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
383 |
384 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
385 |
386 | $this->_refuseNextConnection = TRUE;
387 |
388 | $runUntil = microtime(TRUE) + 8.0;
389 |
390 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
391 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg('tcp://127.0.0.1:9000') . ' --message=' . escapeshellarg('Hello world') . ' --message-count=1 --async', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
392 |
393 | $status = NULL;
394 |
395 | while (microtime(TRUE) <= $runUntil) {
396 |
397 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
398 |
399 | if ($clientProcess !== NULL) {
400 |
401 | $status = proc_get_status($clientProcess);
402 | if (!$status['running']) {
403 |
404 | \PHPWebSockets::Log(LogLevel::INFO, 'Client disappeared');
405 | $clientProcess = NULL;
406 |
407 | }
408 |
409 | }
410 |
411 | }
412 |
413 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
414 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
415 | $this->assertEmpty($this->_connectionList);
416 |
417 | $this->_refuseNextConnection = FALSE;
418 |
419 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
420 |
421 | }
422 |
423 | public function testDoubleClose() {
424 |
425 | \PHPWebSockets::Log(LogLevel::INFO, 'Starting test..' . PHP_EOL);
426 |
427 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
428 |
429 | $closeAt = microtime(TRUE) + 3.0;
430 | $runUntil = $closeAt + 4.0;
431 |
432 | $didSendDisconnect = FALSE;
433 | $didClose = FALSE;
434 |
435 | $descriptorSpec = [['pipe', 'r'], STDOUT, STDERR];
436 | $clientProcess = proc_open('./tests/Helpers/client.php --address=' . escapeshellarg(self::ADDRESS) . ' --message=' . escapeshellarg('Hello world') . ' --message-count=5', $descriptorSpec, $pipes, realpath(__DIR__ . '/../'));
437 |
438 | while (microtime(TRUE) <= $runUntil) {
439 |
440 | $this->_updatesWrapper->update(0.5, $this->_wsServer->getConnections(TRUE));
441 |
442 | if (!$didSendDisconnect) {
443 | $this->assertTrue(proc_get_status($clientProcess)['running'] ?? FALSE);
444 | }
445 |
446 | if ($didSendDisconnect && !$didClose) {
447 |
448 | /** @var \PHPWebSockets\AConnection $connection */
449 | $connection = reset($connections);
450 | $connection->close();
451 |
452 | $didClose = TRUE;
453 |
454 | }
455 |
456 | if (microtime(TRUE) >= $closeAt && !$didSendDisconnect) {
457 |
458 | $connections = $this->_wsServer->getConnections(FALSE);
459 |
460 | $this->assertNotEmpty($connections);
461 |
462 | \PHPWebSockets::Log(LogLevel::INFO, 'Sending disconnect + close');
463 |
464 | /** @var \PHPWebSockets\AConnection $connection */
465 | $connection = reset($connections);
466 | $connection->sendDisconnect(\PHPWebSockets::CLOSECODE_NORMAL);
467 |
468 | $didSendDisconnect = TRUE;
469 |
470 | }
471 |
472 | }
473 |
474 | $status = proc_get_status($clientProcess);
475 |
476 | $this->assertEquals(0, $status['exitcode'], 'Helper did not exit cleanly');
477 | $this->assertEmpty($this->_wsServer->getConnections(FALSE));
478 | $this->assertEmpty($this->_connectionList);
479 |
480 | \PHPWebSockets::Log(LogLevel::INFO, 'Test finished' . PHP_EOL);
481 |
482 | }
483 | }
484 |
--------------------------------------------------------------------------------
/src/PHPWebSockets.php:
--------------------------------------------------------------------------------
1 | 'Continue',
66 | 101 => 'Switching Protocols',
67 | 102 => 'Processing',
68 |
69 | 200 => 'OK',
70 | 201 => 'Created',
71 | 202 => 'Accepted',
72 | 203 => 'Non-Authoritative Information',
73 | 204 => 'No Content',
74 | 205 => 'Reset Content',
75 | 206 => 'Partial Content',
76 | 207 => 'Multi-Status',
77 | 208 => 'Already Reported',
78 | 226 => 'IM Used',
79 |
80 | 300 => 'Multiple Choices',
81 | 301 => 'Moved Permanently',
82 | 302 => 'Found',
83 | 303 => 'See Other',
84 | 304 => 'Not Modified',
85 | 305 => 'Use Proxy',
86 | 306 => 'Switch Proxy',
87 | 307 => 'Temporary Redirect',
88 | 308 => 'Permanent Redirect',
89 |
90 | 400 => 'Bad Request',
91 | 401 => 'Unauthorized',
92 | 402 => 'Payment Required',
93 | 403 => 'Forbidden',
94 | 404 => 'Not Found',
95 | 405 => 'Method Not Allowed',
96 | 406 => 'Not Acceptable',
97 | 407 => 'Proxy Authentication Required',
98 | 408 => 'Request Timeout',
99 | 409 => 'Conflict',
100 | 410 => 'Gone',
101 | 411 => 'Length Required',
102 | 412 => 'Precondition Failed',
103 | 413 => 'Payload Too Large',
104 | 414 => 'URI Too Long',
105 | 415 => 'Unsupported Media Type',
106 | 416 => 'Range Not Satisfiable',
107 | 417 => 'Expectation Failed',
108 | 418 => 'I\'m a teapot',
109 | 421 => 'Misdirected Request',
110 | 422 => 'Unprocessable Entity',
111 | 423 => 'Locked',
112 | 424 => 'Failed Dependency',
113 | 426 => 'Upgrade Required',
114 | 428 => 'Precondition Required',
115 | 429 => 'Too Many Requests',
116 | 431 => 'Request Header Fields Too Large',
117 | 451 => 'Unavailable For Legal Reasons',
118 |
119 | 500 => 'Internal Server Error',
120 | 501 => 'Not Implemented',
121 | 502 => 'Bad Gateway',
122 | 503 => 'Service Unavailable',
123 | 504 => 'Gateway Timeout',
124 | 505 => 'HTTP Version Not Supported',
125 | 506 => 'Variant Also Negotiates',
126 | 507 => 'Insufficient Storage',
127 | 508 => 'Loop Detected',
128 | 510 => 'Not Extended',
129 | 511 => 'Network Authentication Required',
130 | ];
131 |
132 | private static bool $_TraceAllUpdates = FALSE;
133 |
134 | private static ?string $_Version = NULL;
135 |
136 | private static ?LoggerInterface $_Logger = NULL;
137 |
138 | /**
139 | * Checks for updates for the provided IStreamContainer objects
140 | *
141 | * @param \PHPWebSockets\IStreamContainer[] $updateObjects
142 | * @param float|null $timeout The amount of seconds to wait for updates, setting this value to NULL causes this function to block indefinitely until there is an update
143 | *
144 | * @return \Generator|\PHPWebSockets\AUpdate[]
145 | */
146 | public static function MultiUpdate(array $updateObjects, ?float $timeout) : \Generator {
147 |
148 | $timeInt = NULL;
149 | $timePart = 0;
150 |
151 | if ($timeout !== NULL) {
152 |
153 | $timeInt = (int) floor($timeout);
154 | $timePart = (int) (fmod($timeout, 1) * 1000000);
155 |
156 | }
157 |
158 | /** @var \PHPWebSockets\IStreamContainer[] $objectStreamMap */
159 | $objectStreamMap = [];
160 | /** @var resource[] $exceptional */
161 | $exceptional = [];
162 | /** @var resource[] $write */
163 | $write = [];
164 | /** @var resource[] $read */
165 | $read = [];
166 |
167 | foreach ($updateObjects as $object) {
168 |
169 | if (!$object instanceof \PHPWebSockets\IStreamContainer) {
170 | throw new \InvalidArgumentException('Got invalid object, all provided objects should implement ' . \PHPWebSockets\IStreamContainer::class);
171 | }
172 |
173 | yield from $object->beforeStreamSelect();
174 |
175 | $stream = $object->getStream();
176 |
177 | // It is possible the stream closed itself during beforeStreamSelect
178 | if ($stream === NULL) {
179 | continue;
180 | }
181 |
182 | $objectStreamMap[(int) $stream] = $object;
183 | $exceptional[] = $stream;
184 | if (!$object->isWriteBufferEmpty()) {
185 | $write[] = $stream;
186 | }
187 | $read[] = $stream;
188 |
189 | yield from $object->afterStreamSelect();
190 |
191 | }
192 |
193 | if (!empty($read) || !empty($write) || !empty($exceptional)) {
194 |
195 | $streams = @stream_select($read, $write, $exceptional, $timeInt, $timePart); // Stream select filters everything out of the arrays
196 | if ($streams === FALSE) {
197 | yield new \PHPWebSockets\Update\Error(\PHPWebSockets\Update\Error::C_SELECT);
198 | } else {
199 |
200 | if (!empty($read) || !empty($write) || !empty($exceptional)) {
201 | static::Log(LogLevel::DEBUG, 'Read: ' . count($read) . ' Write: ' . count($write) . ' Except: ' . count($exceptional));
202 | }
203 |
204 | foreach ($read as $stream) {
205 |
206 | $object = $objectStreamMap[(int) $stream];
207 | if ($object === NULL) {
208 | static::Log(LogLevel::ERROR, 'Unable to find stream container related to stream during read!');
209 | continue;
210 | }
211 |
212 | yield from $object->handleRead();
213 |
214 | }
215 |
216 | foreach ($write as $stream) {
217 |
218 | $object = $objectStreamMap[(int) $stream];
219 | if ($object === NULL) {
220 | static::Log(LogLevel::ERROR, 'Unable to find stream container related to stream during write!');
221 | continue;
222 | }
223 |
224 | if (!is_resource($object->getStream())) { // Check if it is still open, it could have closed
225 | static::Log(LogLevel::WARNING, 'Unable to complete write for stream container ' . $object . ', connection was closed');
226 | continue;
227 | }
228 |
229 | yield from $object->handleWrite();
230 |
231 | }
232 |
233 | foreach ($exceptional as $stream) {
234 |
235 | $object = $objectStreamMap[(int) $stream];
236 | if ($object === NULL) {
237 | static::Log(LogLevel::ERROR, 'Unable to find stream container related to stream during exceptional read!');
238 | continue;
239 | }
240 |
241 | if (!is_resource($object->getStream())) { // Check if it is still open, it could have closed
242 | static::Log(LogLevel::WARNING, 'Unable to complete exceptional read for stream container, connection was closed');
243 | continue;
244 | }
245 |
246 | static::Log(LogLevel::ERROR, 'Got exceptional for ' . $object);
247 |
248 | yield from $object->handleExceptional();
249 |
250 | }
251 |
252 | }
253 |
254 | }
255 |
256 | }
257 |
258 | /**
259 | * Returns TRUE if the specified code is valid
260 | *
261 | * @param int $code
262 | * @param bool $received If the close code is received as reason
263 | *
264 | * @return bool
265 | */
266 | public static function IsValidCloseCode(int $code, bool $received = TRUE) : bool {
267 |
268 | switch ($code) {
269 | case self::CLOSECODE_NORMAL:
270 | case self::CLOSECODE_ENDPOINT_CLOSING:
271 | case self::CLOSECODE_PROTOCOL_ERROR:
272 | case self::CLOSECODE_UNSUPPORTED_PAYLOAD:
273 | case self::CLOSECODE_INVALID_PAYLOAD:
274 | case self::CLOSECODE_POLICY_VIOLATION:
275 | case self::CLOSECODE_PAYLOAD_TO_LARGE:
276 | case self::CLOSECODE_EXTENSION_NEGOTIATION_FAILURE:
277 | case self::CLOSECODE_UNEXPECTED_CONDITION:
278 | return TRUE;
279 | case self::CLOSECODE_NO_STATUS:
280 | case self::CLOSECODE_ABNORMAL_DISCONNECT:
281 | case self::CLOSECODE_TLS_HANDSHAKE_FAILURE:
282 | return !$received;
283 | default:
284 | return $code >= 3000 && $code <= 4999;
285 | }
286 |
287 | }
288 |
289 | /**
290 | * Returns if the message with the provided opcode should be send with priority
291 | *
292 | * @param int $opcode
293 | *
294 | * @return bool
295 | */
296 | public static function IsPriorityOpcode(int $opcode) : bool {
297 |
298 | /*
299 | * Note:
300 | * We do not consider the opcode for CLOSE to be a priority since this would cause us to send the close frame before finishing our queue
301 | * In those cases it can be possible for the remote site to read both a close and another frame at the same time and process them in that order
302 | * That in return causes it to effectively receive a message from a closed connection
303 | */
304 |
305 | return $opcode !== self::OPCODE_CLOSE_CONNECTION && self::IsControlOpcode($opcode);
306 | }
307 |
308 | /**
309 | * Returns if the provided opcode is a control opcode
310 | *
311 | * @param int $opcode
312 | *
313 | * @return bool
314 | */
315 | public static function IsControlOpcode(int $opcode) : bool {
316 | return $opcode >= 8 && $opcode <= 15;
317 | }
318 |
319 | /**
320 | * Generates a random string
321 | *
322 | * @param int $length
323 | *
324 | * @return string
325 | */
326 | public static function RandomString(int $length) : string {
327 |
328 | $key = '';
329 | for ($i = 0; $i < $length; $i++) {
330 | $key .= chr(mt_rand(32, 93));
331 | }
332 |
333 | return $key;
334 | }
335 |
336 | /**
337 | * Validates a string for UTF8 validity, this allows for matching partial UTF8 string by passing the state each time on resume
338 | *
339 | * @copyright (c) 2008-2009 Bjoern Hoehrmann
340 | *
341 | * @see http://bjoern.hoehrmann.de/utf-8/decoder/dfa/
342 | *
343 | * @param string $str
344 | * @param int &$state
345 | *
346 | * @return bool
347 | */
348 | public static function ValidateUTF8(string $str, int &$state = self::UTF8_ACCEPT) : bool {
349 |
350 | $table = [
351 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 00..1f
352 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 20..3f
353 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 40..5f
354 | 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 60..7f
355 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, 9, // 80..9f
356 | 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, 7, // a0..bf
357 | 8, 8, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, 2, // c0..df
358 | 0xA, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x3, 0x4, 0x3, 0x3, // e0..ef
359 | 0xB, 0x6, 0x6, 0x6, 0x5, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, 0x8, // f0..ff
360 | 0x0, 0x1, 0x2, 0x3, 0x5, 0x8, 0x7, 0x1, 0x1, 0x1, 0x4, 0x6, 0x1, 0x1, 0x1, 0x1, // s0..s0
361 | 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 1, 1, 1, 1, 0, 1, 0, 1, 1, 1, 1, 1, 1, // s1..s2
362 | 1, 2, 1, 1, 1, 1, 1, 2, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, // s3..s4
363 | 1, 2, 1, 1, 1, 1, 1, 1, 1, 2, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, // s5..s6
364 | 1, 3, 1, 1, 1, 1, 1, 3, 1, 3, 1, 1, 1, 1, 1, 1, 1, 3, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // s7..s8
365 | ];
366 |
367 | $len = strlen($str);
368 |
369 | for ($i = 0; $i < $len; $i++) {
370 |
371 | $state = $table[256 + ($state << 4) + $table[ord($str[$i])]];
372 | if ($state === self::UTF8_REJECT) {
373 | return FALSE;
374 | }
375 |
376 | }
377 |
378 | return TRUE;
379 | }
380 |
381 | /**
382 | * Attempts to parse the provided string into key => value pairs based on the HTTP headers syntax
383 | *
384 | * @param string $rawHeaders
385 | *
386 | * @return array
387 | */
388 | public static function ParseHTTPHeaders(string $rawHeaders) : array {
389 |
390 | $headers = [];
391 |
392 | $lines = explode("\n", $rawHeaders);
393 | foreach ($lines as $line) {
394 |
395 | if (strpos($line, ':') !== FALSE) {
396 |
397 | $header = explode(':', $line, 2);
398 | $headers[strtolower(trim($header[0]))] = trim($header[1]);
399 |
400 | } elseif (stripos($line, 'get ') !== FALSE) {
401 |
402 | preg_match('/GET (.*) HTTP/i', $line, $reqResource);
403 | $headers['get'] = trim($reqResource[1]);
404 |
405 | } elseif (preg_match('#HTTP/\d+\.\d+ (\d+)#', $line)) {
406 |
407 | $pieces = explode(' ', $line, 3);
408 | $headers['status-code'] = intval($pieces[1]);
409 | $headers['status-text'] = $pieces[2];
410 |
411 | }
412 |
413 | }
414 |
415 | return $headers;
416 | }
417 |
418 | /**
419 | * Returns the text related to the provided error code, returns NULL if the code is unknown
420 | *
421 | * @param int $errorCode
422 | *
423 | * @return string|null
424 | */
425 | public static function GetStringForStatusCode(int $errorCode) : ?string {
426 | return self::HTTP_STATUSCODES[$errorCode] ?? NULL;
427 | }
428 |
429 | /**
430 | * Returns the version of PHPWebSockets
431 | *
432 | * @return string
433 | */
434 | public static function Version() : string {
435 |
436 | if (self::$_Version !== NULL) {
437 | return self::$_Version;
438 | }
439 |
440 | return self::$_Version = trim(file_get_contents(__DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . 'VERSION'));
441 | }
442 |
443 | /**
444 | * @param \PHPWebSockets\AUpdate $update
445 | *
446 | * @return bool
447 | */
448 | public static function ShouldUpdateTrace(\PHPWebSockets\AUpdate $update) : bool {
449 | return self::$_TraceAllUpdates;
450 | }
451 |
452 | /**
453 | * @param bool $value
454 | *
455 | * @return void
456 | */
457 | public static function SetTraceAllUpdates(bool $value) : void {
458 | self::$_TraceAllUpdates = $value;
459 | }
460 |
461 | /**
462 | * Logs a message
463 | *
464 | * @param string $logLevel The log level
465 | * @param mixed $message The message to log
466 | * @param array $context
467 | *
468 | * @return void
469 | */
470 | public static function Log(string $logLevel, $message, array $context = []) : void {
471 | $logger = self::getLogger();
472 | if ($logger) {
473 | $logger->log($logLevel, $message, $context);
474 | }
475 | }
476 |
477 | /**
478 | * Sets the logger
479 | *
480 | * @param \Psr\Log\LoggerInterface $logger
481 | *
482 | * @return void
483 | */
484 | public static function SetLogger(LoggerInterface $logger) : void {
485 | self::$_Logger = $logger;
486 | }
487 |
488 | /**
489 | * Returns the logger
490 | *
491 | * @return \Psr\Log\LoggerInterface
492 | */
493 | public static function GetLogger() : ?LoggerInterface {
494 | return self::$_Logger;
495 | }
496 |
497 | /**
498 | * The autoloader for non-composer including
499 | * Note: The method is not registered as autoloader, this still has to happen by calling spl_autoload_register([\PHPWebSockets::class, 'Autoload']);
500 | *
501 | * @param string $className
502 | *
503 | * @return void
504 | */
505 | public static function Autoload(string $className) : void {
506 |
507 | if (substr($className, 0, 12) !== 'PHPWebSocket') {
508 | return;
509 | }
510 |
511 | $file = __DIR__ . DIRECTORY_SEPARATOR . str_replace('\\', DIRECTORY_SEPARATOR, $className) . '.php';
512 | if (file_exists($file)) {
513 | require_once($file);
514 | }
515 |
516 | }
517 | }
518 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/Server.php:
--------------------------------------------------------------------------------
1 | getRemoteIP()
55 | *
56 | * @var bool
57 | */
58 | protected $_trustForwardedHeaders = FALSE;
59 |
60 | /**
61 | * The time in seconds in which the stream_socket_accept method has to accept the connection or fail
62 | *
63 | * @var float
64 | */
65 | protected $_socketAcceptTimeout = 5.0;
66 |
67 | /**
68 | * The accepting socket connection
69 | *
70 | * @var \PHPWebSockets\Server\AcceptingConnection
71 | */
72 | protected $_acceptingConnection = NULL;
73 |
74 | /**
75 | * If we should disable the after fork cleanup
76 | *
77 | * @var bool
78 | */
79 | protected $_disableForkCleanup = NULL;
80 |
81 | /**
82 | * The identifier shown to connecting clients, when set to NULL the string PHPWebSockets/ will be used
83 | *
84 | * @var string|null
85 | */
86 | protected $_serverIdentifier = NULL;
87 |
88 | /**
89 | * The FQCN of the class to use for new connections
90 | *
91 | * @var string
92 | */
93 | protected $_connectionClass = Server\Connection::class;
94 |
95 | /**
96 | * The index for the next connection to be inserted at
97 | *
98 | * @var int
99 | */
100 | protected $_connectionIndex = 0;
101 |
102 | /**
103 | * All connections
104 | *
105 | * @var \PHPWebSockets\Server\Connection[]
106 | */
107 | protected $_connections = [];
108 |
109 | /**
110 | * The unique ID for this server
111 | *
112 | * @var int
113 | */
114 | protected $_serverIndex = 0;
115 |
116 | /**
117 | * If the new connection should automatically be accepted
118 | *
119 | * @var bool
120 | */
121 | protected $_autoAccept = TRUE;
122 |
123 | /**
124 | * If we should enable crypto after accept
125 | *
126 | * @var bool
127 | */
128 | protected $_useCrypto = FALSE;
129 |
130 | /**
131 | * The address of the accepting socket
132 | *
133 | * @var string
134 | */
135 | protected $_address = NULL;
136 |
137 | /**
138 | * @var string|null
139 | */
140 | protected $_tag = NULL;
141 |
142 | /**
143 | * Constructs a new webserver
144 | *
145 | * @param string|null $address This should be a protocol://address:port scheme url, if left NULL no accepting socket will be created
146 | * @param array $streamContext The stream context @see https://secure.php.net/manual/en/function.stream-context-create.php
147 | * @param bool $useCrypto If we should enable crypto on newly accepted connections
148 | * @param \Psr\Log\LoggerInterface|null $logger
149 | *
150 | * @return void
151 | */
152 | public function __construct(?string $address = NULL, array $streamContext = [], bool $useCrypto = FALSE, ?LoggerInterface $logger = NULL) {
153 |
154 | if ($logger !== NULL) {
155 | $this->setLogger($logger);
156 | }
157 |
158 | $this->_serverIndex = self::$_ServerCounter;
159 | $this->_useCrypto = $useCrypto;
160 | $this->_address = $address;
161 |
162 | self::$_ServerCounter++;
163 |
164 | if ($this->_address !== NULL) {
165 |
166 | $pos = strpos($this->_address, '://');
167 | if ($pos !== FALSE) {
168 |
169 | $protocol = substr($this->_address, 0, $pos);
170 | switch ($protocol) {
171 | case 'unix':
172 | case 'udg':
173 |
174 | $path = substr($this->_address, $pos + 3);
175 | if (file_exists($path)) {
176 |
177 | $this->_log(LogLevel::WARNING, 'Unix socket "' . $path . '" still exists, unlinking!');
178 | if (!unlink($path)) {
179 | throw new \RuntimeException('Unable to unlink file "' . $path . '"');
180 | }
181 |
182 | } else {
183 |
184 | $dir = pathinfo($path, PATHINFO_DIRNAME);
185 | if (!is_dir($dir)) {
186 |
187 | $this->_log(LogLevel::DEBUG, 'Directory "' . $dir . '" does not exist, creating..');
188 | mkdir($dir, 0770, TRUE);
189 |
190 | }
191 |
192 | }
193 |
194 | break;
195 | }
196 |
197 | }
198 |
199 | $errCode = NULL;
200 | $errString = NULL;
201 | $acceptingSocket = @stream_socket_server($this->_address, $errCode, $errString, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN, stream_context_create($streamContext));
202 | if (!$acceptingSocket) {
203 | throw new \RuntimeException('Unable to create webserver on address ' . $this->_address . ' : ' . $errString, $errCode);
204 | }
205 |
206 | $this->_acceptingConnection = new Server\AcceptingConnection($this, $acceptingSocket);
207 |
208 | $this->_log(LogLevel::INFO, 'Opened websocket on ' . $this->_address);
209 |
210 | }
211 |
212 | }
213 |
214 | /**
215 | * Creates a new client/connection pair to be used in fork communication
216 | *
217 | * @return \PHPWebSockets\AConnection[]
218 | */
219 | public function createServerClientPair() : array {
220 |
221 | [$server, $client] = stream_socket_pair(STREAM_PF_UNIX, STREAM_SOCK_STREAM, STREAM_IPPROTO_IP);
222 |
223 | /** @var \PHPWebSockets\Server\Connection $serverConnection */
224 | $serverConnection = new $this->_connectionClass($this, $server, '', $this->_connectionIndex);
225 | $this->_connections[$this->_connectionIndex] = $serverConnection;
226 |
227 | $this->_log(LogLevel::DEBUG, 'Created new connection: ' . $serverConnection);
228 |
229 | $this->_connectionIndex++;
230 |
231 | $clientConnection = new Client();
232 | $clientConnection->setMasksPayload(FALSE);
233 | $clientConnection->connectToResource($client);
234 |
235 | return [$serverConnection, $clientConnection];
236 | }
237 |
238 | /**
239 | * Checks for updates
240 | *
241 | * @param float|null $timeout The amount of seconds to wait for updates, setting this value to NULL causes this function to block indefinitely until there is an update
242 | *
243 | * @return \Generator|\PHPWebSockets\AUpdate[]
244 | */
245 | public function update(?float $timeout) : \Generator {
246 | yield from \PHPWebSockets::MultiUpdate($this->getConnections(TRUE), $timeout);
247 | }
248 |
249 | /**
250 | * Gets called by the accepting web socket to notify the server that a new connection attempt has occurred
251 | *
252 | * @return \Generator|\PHPWebSockets\AUpdate[]
253 | */
254 | public function gotNewConnection() : \Generator {
255 |
256 | if (!$this->_autoAccept) {
257 | yield new Update\Read(Update\Read::C_NEW_SOCKET_CONNECTION_AVAILABLE, NULL);
258 | } else {
259 | yield from $this->acceptNewConnection();
260 | }
261 |
262 | }
263 |
264 | /**
265 | * Accepts a new connection from the accepting socket
266 | *
267 | * @return \Generator|\PHPWebSockets\AUpdate[]
268 | */
269 | public function acceptNewConnection() : \Generator {
270 |
271 | if ($this->_acceptingConnection === NULL) {
272 | throw new \LogicException('This server has no accepting connection, unable to accept a new connection!');
273 | }
274 |
275 | $peerName = '';
276 | $newStream = stream_socket_accept($this->_acceptingConnection->getStream(), $this->getSocketAcceptTimeout(), $peerName);
277 | if (!$newStream) {
278 | throw new \RuntimeException('Unable to accept stream socket!');
279 | }
280 |
281 | $newConnection = new $this->_connectionClass($this, $newStream, $peerName, $this->_connectionIndex);
282 | $this->_connections[$this->_connectionIndex] = $newConnection;
283 |
284 | $this->_log(LogLevel::DEBUG, 'Got new connection: ' . $newConnection);
285 |
286 | $this->_connectionIndex++;
287 |
288 | yield new Update\Read(Update\Read::C_NEW_SOCKET_CONNECTED, $newConnection);
289 |
290 | }
291 |
292 | /**
293 | * Generates a error response for the provided code
294 | *
295 | * @param int $errorCode
296 | * @param string $fallbackErrorString
297 | *
298 | * @return string
299 | */
300 | public function getErrorPageForCode(int $errorCode, string $fallbackErrorString = 'Unknown error code') : string {
301 |
302 | $replaceFields = [
303 | '%errorCode%' => (string) $errorCode,
304 | '%errorString%' => \PHPWebSockets::GetStringForStatusCode($errorCode) ?: $fallbackErrorString,
305 | '%serverIdentifier%' => $this->getServerIdentifier(),
306 | ];
307 |
308 | return str_replace(array_keys($replaceFields), array_values($replaceFields), "HTTP/1.1 %errorCode% %errorString%\r\nServer: %serverIdentifier%\r\n\r\n%errorCode% %errorString%%errorCode% %errorString%
%serverIdentifier%
\r\n\r\n");
309 | }
310 |
311 | /**
312 | * Attempts to return the connection object related to the provided stream
313 | *
314 | * @param resource $stream
315 | *
316 | * @return \PHPWebSockets\Server\Connection|null
317 | */
318 | public function getConnectionByStream($stream) : ?Server\Connection {
319 |
320 | foreach ($this->_connections as $connection) {
321 |
322 | if ($stream === $connection->getStream()) {
323 | return $connection;
324 | }
325 |
326 | }
327 |
328 | return NULL;
329 | }
330 |
331 | /**
332 | * Returns the server identifier string reported to clients
333 | *
334 | * @return string
335 | */
336 | public function getServerIdentifier() : string {
337 | return $this->_serverIdentifier ?? 'PHPWebSockets/' . \PHPWebSockets::Version();
338 | }
339 |
340 | /**
341 | * Sets the server identifier string reported to clients
342 | *
343 | * @param string|null $identifier
344 | *
345 | * @return void
346 | */
347 | public function setServerIdentifier(?string $identifier) : void {
348 | $this->_serverIdentifier = $identifier;
349 | }
350 |
351 | /**
352 | * Returns if the provided connection in owned by this server
353 | *
354 | * @param \PHPWebSockets\Server\Connection $connection
355 | *
356 | * @return bool
357 | */
358 | public function hasConnection(Server\Connection $connection) : bool {
359 | return in_array($connection, $this->_connections, TRUE);
360 | }
361 |
362 | /**
363 | * Returns the accepting connection
364 | *
365 | * @return \PHPWebSockets\Server\AcceptingConnection|null
366 | */
367 | public function getAcceptingConnection() : ?Server\AcceptingConnection {
368 | return $this->_acceptingConnection;
369 | }
370 |
371 | /**
372 | * Returns all connections this server has
373 | *
374 | * @param bool $includeAccepting
375 | *
376 | * @return \PHPWebSockets\Server\Connection[]
377 | */
378 | public function getConnections(bool $includeAccepting = FALSE) : array {
379 |
380 | $ret = $this->_connections;
381 | if ($includeAccepting) {
382 |
383 | $acceptingConnection = $this->getAcceptingConnection();
384 | if ($acceptingConnection !== NULL && $acceptingConnection->isOpen()) {
385 | array_unshift($ret, $acceptingConnection); // Insert the accepting connection on the first index
386 | }
387 |
388 | }
389 |
390 | return $ret;
391 | }
392 |
393 | /**
394 | * Sends a disconnect message to all clients
395 | *
396 | * @param int $closeCode
397 | * @param string $reason
398 | *
399 | * @return void
400 | */
401 | public function disconnectAll(int $closeCode, string $reason = '') : void {
402 |
403 | foreach ($this->getConnections() as $connection) {
404 | $connection->sendDisconnect($closeCode, $reason);
405 | }
406 |
407 | }
408 |
409 | /**
410 | * Returns the bind address for this websocket
411 | *
412 | * @return string
413 | */
414 | public function getAddress() : string {
415 | return $this->_address;
416 | }
417 |
418 | /**
419 | * This should be called after a process has been fork with the PID returned from pcntl_fork, this ensures that the connection is closed in the new fork without interrupting the main process
420 | *
421 | * @param int $pid
422 | *
423 | * @return void
424 | */
425 | public function processDidFork(int $pid) : void {
426 |
427 | if ($this->_disableForkCleanup) {
428 | return;
429 | }
430 |
431 | if ($pid === 0) { // We are in the new fork
432 |
433 | $this->_cleanupAcceptingConnectionOnClose = FALSE;
434 | $this->close();
435 |
436 | }
437 |
438 | }
439 |
440 | /**
441 | * Removes the specified connection from the connections array and closes it if open
442 | *
443 | * @param \PHPWebSockets\Server\Connection $connection
444 | * @param bool $closeConnection
445 | *
446 | * @return void
447 | */
448 | public function removeConnection(Server\Connection $connection, bool $closeConnection = TRUE) : void {
449 |
450 | if ($connection->getServer() !== $this) {
451 | throw new \LogicException('Unable to remove connection ' . $connection . ', this is not our connection!');
452 | }
453 |
454 | $this->_log(LogLevel::DEBUG, 'Removing ' . $connection);
455 |
456 | if ($closeConnection && $connection->isOpen()) {
457 | $connection->close();
458 | }
459 |
460 | unset($this->_connections[$connection->getIndex()]);
461 |
462 | }
463 |
464 | /**
465 | * Sets if the X-Forwarded-For and X-Client-IP headers should be trusted and returned in connection->getRemoteIP()
466 | *
467 | * @param bool $trust
468 | *
469 | * @return void
470 | */
471 | public function setTrustForwardedHeaders(bool $trust) : void {
472 | $this->_trustForwardedHeaders = $trust;
473 | }
474 |
475 | /**
476 | * @return bool
477 | */
478 | public function getTrustForwardedHeaders() : bool {
479 | return $this->_trustForwardedHeaders;
480 | }
481 |
482 | /**
483 | * Sets the time in seconds in which the stream_socket_accept method has to accept the connection or fail
484 | *
485 | * @param float $timeout
486 | *
487 | * @return void
488 | */
489 | public function setSocketAcceptTimeout(float $timeout) : void {
490 | $this->_socketAcceptTimeout = $timeout;
491 | }
492 |
493 | /**
494 | * Returns the time in seconds in which the stream_socket_accept method has to accept the connection or fail
495 | *
496 | * @return float
497 | */
498 | public function getSocketAcceptTimeout() : float {
499 | return $this->_socketAcceptTimeout;
500 | }
501 |
502 | /**
503 | * Sets if we should disable the cleanup which happens after forking
504 | *
505 | * @param bool $disableForkCleanup
506 | *
507 | * @return void
508 | */
509 | public function setDisableForkCleanup(bool $disableForkCleanup) : void {
510 | $this->_disableForkCleanup = $disableForkCleanup;
511 | }
512 |
513 | /**
514 | * Returns if we should disable the cleanup which happens after forking
515 | *
516 | * @return bool
517 | */
518 | public function getDisableForkCleanup() : bool {
519 | return $this->_disableForkCleanup;
520 | }
521 |
522 | /**
523 | * Sets if we should automatically accept the connection
524 | *
525 | * @param bool $autoAccept
526 | *
527 | * @return void
528 | */
529 | public function setAutoAccept(bool $autoAccept) : void {
530 | $this->_autoAccept = $autoAccept;
531 | }
532 |
533 | /**
534 | * Sets the class that will be our connection, this has to be an extension of \PHPWebSockets\Server\Connection
535 | *
536 | * @param string $class
537 | *
538 | * @return void
539 | */
540 | public function setConnectionClass(string $class) : void {
541 |
542 | if (!is_subclass_of($class, Server\Connection::class, TRUE)) {
543 | throw new \InvalidArgumentException('The provided class has to extend ' . Server\Connection::class);
544 | }
545 |
546 | $this->_connectionClass = $class;
547 |
548 | }
549 |
550 | /**
551 | * Returns if we accept the connection automatically
552 | *
553 | * @return bool
554 | */
555 | public function getAutoAccept() : bool {
556 | return $this->_autoAccept;
557 | }
558 |
559 | /**
560 | * Returns if we enable crypto after stream_socket_accept
561 | *
562 | * @return bool
563 | */
564 | public function usesCrypto() : bool {
565 | return $this->_useCrypto;
566 | }
567 |
568 | /**
569 | * Closes the webserver, note: clients should be notified beforehand that we are disconnecting, calling close while having connected clients will result in an improper disconnect
570 | *
571 | * @return void
572 | */
573 | public function close() : void {
574 |
575 | foreach ($this->_connections as $connection) {
576 | $connection->close();
577 | }
578 |
579 | if ($this->_acceptingConnection !== NULL) {
580 |
581 | if ($this->_acceptingConnection->isOpen()) {
582 | $this->_acceptingConnection->close($this->_cleanupAcceptingConnectionOnClose);
583 | }
584 |
585 | $this->_acceptingConnection = NULL;
586 |
587 | }
588 |
589 | }
590 |
591 | /**
592 | * @param string|null $tag
593 | */
594 | public function setTag(?string $tag) : void {
595 | $this->_tag = $tag;
596 | }
597 |
598 | /**
599 | * @return string|null
600 | */
601 | public function getTag() : ?string {
602 | return $this->_tag;
603 | }
604 |
605 | public function __destruct() {
606 | $this->close();
607 | }
608 |
609 | public function __toString() {
610 |
611 | $tag = $this->getTag();
612 |
613 | return 'WSServer ' . $this->_serverIndex . ($tag === NULL ? '' : ' (Tag: ' . $tag . ')');
614 | }
615 | }
616 |
--------------------------------------------------------------------------------
/src/PHPWebSockets/UpdatesWrapper.php:
--------------------------------------------------------------------------------
1 | $container) {
89 |
90 | if (!$container instanceof IStreamContainer) {
91 | throw new \InvalidArgumentException('Entry at key ' . $key . ' is not an instance of ' . IStreamContainer::class);
92 | }
93 |
94 | $this->addStreamContainer($container);
95 |
96 | }
97 |
98 | }
99 |
100 | /**
101 | * @param \PHPWebSockets\IStreamContainer $container
102 | *
103 | * @return void
104 | */
105 | public function addStreamContainer(IStreamContainer $container) : void {
106 | $this->_streamContainers[] = $container;
107 | }
108 |
109 | /**
110 | * @param \PHPWebSockets\IStreamContainer $container
111 | *
112 | * @return bool
113 | */
114 | public function removeStreamContainer(IStreamContainer $container) : bool {
115 |
116 | $key = array_search($container, $this->_streamContainers, TRUE);
117 | if ($key !== FALSE) {
118 | unset($this->_streamContainers[$key]);
119 | }
120 |
121 | return $key !== FALSE;
122 | }
123 |
124 | /**
125 | * Creates a run loop
126 | *
127 | * @param float|null $timeout
128 | * @param callable|null $runLoop
129 | *
130 | * @return void
131 | */
132 | public function run(?float $timeout = NULL, ?callable $runLoop = NULL) : void {
133 |
134 | $this->_shouldRun = TRUE;
135 | while ($this->_shouldRun) {
136 |
137 | $this->update($timeout);
138 |
139 | if ($runLoop) {
140 | $runLoop($this);
141 | }
142 |
143 | }
144 |
145 | }
146 |
147 | /**
148 | * @return \PHPWebSockets\AUpdate|null
149 | */
150 | public function getLastUpdate() : ?AUpdate {
151 | return $this->_lastUpdate;
152 | }
153 |
154 | /**
155 | * @return void
156 | */
157 | public function stop() : void {
158 | $this->_shouldRun = FALSE;
159 | }
160 |
161 | /**
162 | * @param float|null $timeout The amount of seconds to wait for updates, setting this value to NULL causes this function to block indefinitely until there is an update
163 | * @param \PHPWebSockets\IStreamContainer[] $tempStreams Streams that will be handled this iteration only
164 | *
165 | * @return void
166 | */
167 | public function update(?float $timeout, array $tempStreams = []) : void {
168 |
169 | $updates = \PHPWebSockets::MultiUpdate(array_merge($this->_streamContainers, $tempStreams), $timeout);
170 | foreach ($updates as $update) {
171 |
172 | $this->_lastUpdate = $update;
173 |
174 | if ($update instanceof Update\Read) {
175 |
176 | $code = $update->getCode();
177 | switch ($code) {
178 | case Update\Read::C_NEW_CONNECTION:
179 | $this->_onNewConnection($update);
180 | break;
181 | case Update\Read::C_READ:
182 | $this->_onRead($update);
183 | break;
184 | case Update\Read::C_PING:
185 | $this->_onPing($update);
186 | break;
187 | case Update\Read::C_PONG:
188 | $this->_onPong($update);
189 | break;
190 | case Update\Read::C_SOCK_DISCONNECT:
191 | $this->_onSocketDisconnect($update);
192 | break;
193 | case Update\Read::C_CONNECTION_DENIED:
194 | $this->_onConnectionRefused($update);
195 | break;
196 | case Update\Read::C_CONNECTION_ACCEPTED:
197 | $this->_onConnect($update);
198 | break;
199 | case Update\Read::C_READ_DISCONNECT:
200 | $this->_onDisconnect($update);
201 | break;
202 | case Update\Read::C_NEW_SOCKET_CONNECTED:
203 | $this->_onSocketConnect($update);
204 | break;
205 | case Update\Read::C_NEW_SOCKET_CONNECTION_AVAILABLE:
206 | $this->_onSocketConnectionAvailable($update);
207 | break;
208 | default:
209 | throw new \UnexpectedValueException('Unknown or unsupported update code for read: ' . $code);
210 | }
211 |
212 | } elseif ($update instanceof Update\Error) {
213 |
214 | $code = $update->getCode();
215 | switch ($code) {
216 | case Update\Error::C_SELECT:
217 | $this->_onSelectInterrupt($update);
218 | break;
219 | case Update\Error::C_READ:
220 | $this->_onReadFail($update);
221 | break;
222 | case Update\Error::C_READ_EMPTY:
223 | $this->_onReadEmpty($update);
224 | break;
225 | case Update\Error::C_READ_UNHANDLED:
226 | $this->_onUnhandledRead($update);
227 | break;
228 | case Update\Error::C_READ_HANDSHAKE_FAILURE:
229 | $this->_onHandshakeFailure($update);
230 | break;
231 | case Update\Error::C_READ_HANDSHAKE_TO_LARGE:
232 | $this->_onHandshakeToLarge($update);
233 | break;
234 | case Update\Error::C_READ_INVALID_PAYLOAD:
235 | $this->_onInvalidPayload($update);
236 | break;
237 | case Update\Error::C_READ_INVALID_HEADERS:
238 | $this->_onInvalidHeaders($update);
239 | break;
240 | case Update\Error::C_READ_UNEXPECTED_DISCONNECT:
241 | $this->_onUnexpectedDisconnect($update);
242 | break;
243 | case Update\Error::C_READ_PROTOCOL_ERROR:
244 | $this->_onProtocolError($update);
245 | break;
246 | case Update\Error::C_READ_RSV_BIT_SET:
247 | $this->_onInvalidRSVBit($update);
248 | break;
249 | case Update\Error::C_WRITE:
250 | $this->_writeError($update);
251 | break;
252 | case Update\Error::C_ACCEPT_TIMEOUT_PASSED:
253 | $this->_acceptTimeoutPassed($update);
254 | break;
255 | case Update\Error::C_WRITE_INVALID_TARGET_STREAM:
256 | $this->_writeStreamInvalid($update);
257 | break;
258 | case Update\Error::C_READ_DISCONNECT_DURING_HANDSHAKE:
259 | $this->_onDisconnectDuringHandshake($update);
260 | break;
261 | case Update\Error::C_DISCONNECT_TIMEOUT:
262 | // Ignored for now since it already triggers a disconnect event
263 | break;
264 | case Update\Error::C_READ_NO_STREAM_FOR_NEW_MESSAGE:
265 | $this->_onInvalidStream($update);
266 | break;
267 | case Update\Error::C_ASYNC_CONNECT_FAILED:
268 | $this->_onAsyncConnectFailed($update);
269 | break;
270 | default:
271 | throw new \UnexpectedValueException('Unknown or unsupported update code for error: ' . $code);
272 | }
273 |
274 | } else {
275 | throw new \UnexpectedValueException('Got unhandled update class: ' . get_class($update));
276 | }
277 |
278 | }
279 |
280 | }
281 |
282 | /*
283 | * Handler setters
284 | */
285 |
286 | /**
287 | * @param callable|null $callable
288 | *
289 | * @return void
290 | */
291 | public function setClientConnectedHandler(?callable $callable = NULL) : void {
292 | $this->_clientConnectedHandler = $callable;
293 | }
294 |
295 | /**
296 | * @param callable|null $callable
297 | *
298 | * @return void
299 | */
300 | public function setNewConnectionHandler(?callable $callable = NULL) : void {
301 | $this->_newConnectionHandler = $callable;
302 | }
303 |
304 | /**
305 | * @param callable|null $callable
306 | *
307 | * @return void
308 | */
309 | public function setLastContactHandler(?callable $callable = NULL) : void {
310 | $this->_lastContactHandler = $callable;
311 | }
312 |
313 | /**
314 | * @param callable|null $callable
315 | *
316 | * @return void
317 | */
318 | public function setMessageHandler(?callable $callable = NULL) : void {
319 | $this->_newMessageHandler = $callable;
320 | }
321 |
322 | /**
323 | * @param callable|null $callable
324 | *
325 | * @return void
326 | */
327 | public function setDisconnectHandler(?callable $callable = NULL) : void {
328 | $this->_disconnectHandler = $callable;
329 | }
330 |
331 | /**
332 | * @param callable|null $callable
333 | *
334 | * @return void
335 | */
336 | public function setErrorHandler(?callable $callable = NULL) : void {
337 | $this->_errorHandler = $callable;
338 | }
339 |
340 | /*
341 | * Triggers
342 | */
343 |
344 | private function _triggerNewConnectionHandler(Connection $connection) : void {
345 |
346 | $accept = NULL;
347 | if ($this->_newConnectionHandler) {
348 | $accept = call_user_func($this->_newConnectionHandler, $connection);
349 | }
350 |
351 | if ($accept === TRUE) {
352 | $connection->accept();
353 | } elseif ($accept === FALSE) {
354 | $connection->deny(400);
355 | }
356 |
357 | }
358 |
359 | private function _triggerNewMessageHandler(AConnection $connection, string $message, int $opcode) : void {
360 | if ($this->_newMessageHandler) {
361 | call_user_func($this->_newMessageHandler, $connection, $message, $opcode);
362 | }
363 | }
364 |
365 | private function _triggerLastContactHandler(AConnection $connection) {
366 | if ($this->_lastContactHandler) {
367 | call_user_func($this->_lastContactHandler, $connection);
368 | }
369 | }
370 |
371 | private function _triggerDisconnectHandler(AConnection $connection, bool $wasClean, ?string $data = NULL) : void {
372 |
373 | $reason = '';
374 | $code = 0;
375 |
376 | if ($data !== NULL) {
377 |
378 | $dataLen = strlen($data);
379 | if ($dataLen >= 2) {
380 |
381 | $code = unpack('n', substr($data, 0, 2))[1];
382 |
383 | if ($dataLen > 2) {
384 | $reason = substr($data, 2);
385 | }
386 |
387 | }
388 |
389 | }
390 |
391 | if ($this->_disconnectHandler) {
392 | call_user_func($this->_disconnectHandler, $connection, $wasClean, $code, $reason);
393 | }
394 | }
395 |
396 | private function _triggerErrorHandler(AConnection $connection, int $code) : void {
397 | if ($this->_errorHandler) {
398 | call_user_func($this->_errorHandler, $connection, $code);
399 | }
400 | }
401 |
402 | private function _triggerConnected(Client $client) : void {
403 | if ($this->_clientConnectedHandler) {
404 | call_user_func($this->_clientConnectedHandler, $client);
405 | }
406 | }
407 |
408 | /*
409 | * Read events
410 | */
411 |
412 | private function _onNewConnection(Update\Read $update) : void {
413 |
414 | /** @var \PHPWebSockets\Server\Connection $source */
415 | $source = $update->getSourceConnection();
416 |
417 | $this->_triggerNewConnectionHandler($source);
418 |
419 | if ($source->isAccepted()) {
420 | $this->_triggerLastContactHandler($source);
421 | }
422 |
423 | }
424 |
425 | private function _onRead(Update\Read $update) : void {
426 |
427 | $source = $update->getSourceConnection();
428 |
429 | $this->_triggerLastContactHandler($source);
430 | $this->_triggerNewMessageHandler($source, $update->getMessage(), $update->getOpcode());
431 |
432 | }
433 |
434 | private function _onPing(Update\Read $update) : void {
435 | $this->_triggerLastContactHandler($update->getSourceConnection());
436 | }
437 |
438 | private function _onPong(Update\Read $update) : void {
439 | $this->_triggerLastContactHandler($update->getSourceConnection());
440 | }
441 |
442 | private function _onSocketDisconnect(Update\Read $update) : void {
443 |
444 | $source = $update->getSourceConnection();
445 | $index = $source->getResourceIndex();
446 |
447 | if (!isset($this->_handledDisconnects[$index])) {
448 |
449 | /*
450 | * If the socket has closed without the disconnect handler being triggered we'll trigger it anyway
451 | */
452 |
453 | $this->_triggerDisconnectHandler($source, FALSE, NULL);
454 |
455 | }
456 |
457 | unset($this->_handledDisconnects[$index]);
458 |
459 | }
460 |
461 | private function _onConnectionRefused(Update\Read $update) : void {
462 |
463 | $source = $update->getSourceConnection();
464 |
465 | $this->_triggerDisconnectHandler($source, TRUE, $update->getMessage());
466 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
467 |
468 | }
469 |
470 | private function _onConnect(Update\Read $update) : void {
471 |
472 | /** @var \PHPWebSockets\Client $source */
473 | $source = $update->getSourceConnection();
474 |
475 | $this->_triggerLastContactHandler($source);
476 | $this->_triggerConnected($source);
477 |
478 | }
479 |
480 | private function _onDisconnect(Update\Read $update) : void {
481 |
482 | $source = $update->getSourceConnection();
483 |
484 | $this->_triggerDisconnectHandler($source, TRUE, $update->getMessage());
485 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
486 |
487 | }
488 |
489 | private function _onSocketConnect(Update\Read $update) : void {
490 | // Todo
491 | }
492 |
493 | private function _onSocketConnectionAvailable(Update\Read $update) : void {
494 | // Todo
495 | }
496 |
497 | /*
498 | * Error events
499 | */
500 |
501 | private function _onSelectInterrupt(Update\Error $update) : void {
502 | // Nothing
503 | }
504 |
505 | private function _onReadFail(Update\Error $update) : void {
506 | $this->_triggerErrorHandler($update->getSourceConnection(), $update->getCode());
507 | }
508 |
509 | private function _onReadEmpty(Update\Error $update) : void {
510 | $this->_triggerErrorHandler($update->getSourceConnection(), $update->getCode());
511 | }
512 |
513 | private function _onUnhandledRead(Update\Error $update) : void {
514 | // Nothing
515 | }
516 |
517 | private function _onHandshakeFailure(Update\Error $update) : void {
518 |
519 | $source = $update->getSourceConnection();
520 |
521 | $this->_triggerErrorHandler($source, $update->getCode());
522 |
523 | }
524 |
525 | private function _onHandshakeToLarge(Update\Error $update) : void {
526 |
527 | $source = $update->getSourceConnection();
528 |
529 | $this->_triggerErrorHandler($source, $update->getCode());
530 |
531 | }
532 |
533 | private function _onInvalidPayload(Update\Error $update) : void {
534 |
535 | $source = $update->getSourceConnection();
536 |
537 | $this->_triggerErrorHandler($source, $update->getCode());
538 |
539 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
540 |
541 | }
542 |
543 | private function _onInvalidHeaders(Update\Error $update) : void {
544 | $this->_triggerErrorHandler($update->getSourceConnection(), $update->getCode());
545 | }
546 |
547 | private function _onUnexpectedDisconnect(Update\Error $update) : void {
548 |
549 | $source = $update->getSourceConnection();
550 |
551 | $this->_triggerErrorHandler($source, $update->getCode());
552 | $this->_triggerDisconnectHandler($source, FALSE, NULL);
553 |
554 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
555 |
556 | }
557 |
558 | private function _onProtocolError(Update\Error $update) : void {
559 |
560 | $source = $update->getSourceConnection();
561 |
562 | $this->_triggerErrorHandler($source, $update->getCode());
563 |
564 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
565 |
566 | }
567 |
568 | private function _onInvalidRSVBit(Update\Error $update) : void {
569 |
570 | $source = $update->getSourceConnection();
571 |
572 | $this->_triggerErrorHandler($source, $update->getCode());
573 |
574 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
575 |
576 | }
577 |
578 | private function _writeError(Update\Error $update) : void {
579 | $this->_triggerErrorHandler($update->getSourceConnection(), $update->getCode());
580 | }
581 |
582 | private function _acceptTimeoutPassed(Update\Error $update) : void {
583 |
584 | $source = $update->getSourceConnection();
585 |
586 | $this->_triggerErrorHandler($source, $update->getCode());
587 |
588 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
589 |
590 | }
591 |
592 | private function _writeStreamInvalid(Update\Error $update) : void {
593 |
594 | $source = $update->getSourceConnection();
595 |
596 | $this->_triggerErrorHandler($source, $update->getCode());
597 | $this->_triggerDisconnectHandler($source, FALSE, NULL);
598 |
599 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
600 |
601 | }
602 |
603 | private function _onInvalidStream(Update\Error $update) : void {
604 |
605 | // Not sure if we should implement a callback for this since the implementor did this on purpose
606 |
607 | }
608 |
609 | private function _onDisconnectDuringHandshake(Update\Error $update) : void {
610 | // $this->_triggerErrorHandler($update->getSourceConnection(), $update->getCode());
611 | }
612 |
613 | private function _onAsyncConnectFailed(Update\Error $update) : void {
614 |
615 | $source = $update->getSourceConnection();
616 |
617 | $this->_triggerDisconnectHandler($source, FALSE, NULL);
618 | $this->_handledDisconnects[$source->getResourceIndex()] = TRUE;
619 |
620 | }
621 | }
622 |
--------------------------------------------------------------------------------