Passed
Push — master ( c03036...d048db )
by Shahrad
02:03
created

WebSocket::onConnection()   B

Complexity

Conditions 11
Paths 28

Size

Total Lines 20
Code Lines 10

Duplication

Lines 0
Ratio 0 %

Importance

Changes 1
Bugs 0 Features 0
Metric Value
cc 11
eloc 10
nc 28
nop 0
dl 0
loc 20
rs 7.3166
c 1
b 0
f 0

How to fix   Complexity   

Long Method

Small methods make your code easier to understand, in particular if combined with a good name. Besides, if your method is small, finding a good name is usually much easier.

For example, if you find yourself adding comments to a method's body, this is usually a good sign to extract the commented part to a new method, and use the comment as a starting point when coming up with a good name for this new method.

Commonly applied refactorings include:

1
<?php
2
3
namespace EasyHttp;
4
5
use EasyHttp\Contracts\CommonsContract;
6
use EasyHttp\Contracts\WscCommonsContract;
7
use EasyHttp\Exceptions\BadOpcodeException;
8
use EasyHttp\Exceptions\ConnectionException;
9
use EasyHttp\Exceptions\WebSocketException;
10
use EasyHttp\Traits\WSClientTrait;
11
12
/**
13
 * WebSocket class
14
 *
15
 * @link    https://github.com/shahradelahi/easy-http
16
 * @author  Shahrad Elahi (https://github.com/shahradelahi)
17
 * @license https://github.com/shahradelahi/easy-http/blob/master/LICENSE (MIT License)
18
 */
19
class WebSocket implements WscCommonsContract
20
{
21
22
	use WSClientTrait;
23
24
	/**
25
	 * @var callable|null
26
	 */
27
	public $onOpen = null;
28
29
	/**
30
	 * @var callable|null
31
	 */
32
	public $onClose = null;
33
34
	/**
35
	 * @var callable|null
36
	 */
37
	public $onError = null;
38
39
	/**
40
	 * @var callable|null
41
	 */
42
	public $onMessage = null;
43
44
	/**
45
	 * @var callable|null
46
	 */
47
	public $onWhile = null;
48
49
	/**
50
	 * App version
51
	 *
52
	 * @var string
53
	 */
54
	public const VERSION = 'v1.2.0';
55
56
	/**
57
	 * @var resource|bool
58
	 */
59
	private $socket;
60
61
	/**
62
	 * @var bool
63
	 */
64
	private bool $isConnected = false;
65
66
	/**
67
	 * @var bool
68
	 */
69
	private bool $isClosing = false;
70
71
	/**
72
	 * @var string
73
	 */
74
	private string $lastOpcode;
75
76
	/**
77
	 * @var float|int
78
	 */
79
	private float|int $closeStatus;
80
81
	/**
82
	 * @var string|null
83
	 */
84
	private ?string $hugePayload;
85
86
	/**
87
	 * @var array|int[]
88
	 */
89
	private static array $opcodes = [
90
		CommonsContract::EVENT_TYPE_CONTINUATION => 0,
91
		CommonsContract::EVENT_TYPE_TEXT => 1,
92
		CommonsContract::EVENT_TYPE_BINARY => 2,
93
		CommonsContract::EVENT_TYPE_CLOSE => 8,
94
		CommonsContract::EVENT_TYPE_PING => 9,
95
		CommonsContract::EVENT_TYPE_PONG => 10,
96
	];
97
98
	/**
99
	 * @var WebSocketConfig
100
	 */
101
	protected WebSocketConfig $config;
102
103
	/**
104
	 * @var string
105
	 */
106
	protected string $socketUrl;
107
108
	/**
109
	 * Sets parameters for Web Socket Client intercommunication
110
	 *
111
	 * @param ?SocketClient $client leave it empty if you want to use default socket client
112
	 */
113
	public function __construct(?SocketClient $client = null)
114
	{
115
		if ($client instanceof SocketClient) {
116
117
			$this->onOpen = function ($socket) use ($client) {
118
				$client->onOpen($socket);
119
			};
120
121
			$this->onClose = function ($socket, int $closeStatus) use ($client) {
122
				$client->onClose($socket, $closeStatus);
123
			};
124
125
			$this->onError = function ($socket, WebSocketException $exception) use ($client) {
126
				$client->onError($socket, $exception);
127
			};
128
129
			$this->onMessage = function ($socket, string $message) use ($client) {
130
				$client->onMessage($socket, $message);
131
			};
132
		}
133
134
		$this->config = $config ?? new WebSocketConfig();
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $config seems to never exist and therefore isset should always be false.
Loading history...
135
	}
136
137
	/**
138
	 * @param string $socketUrl string that represents the URL of the Web Socket server. e.g. ws://localhost:1337 or wss://localhost:1337
139
	 * @param ?WebSocketConfig $config The configuration for the Web Socket client
140
	 */
141
	public function connect(string $socketUrl, ?WebSocketConfig $config = null): void
142
	{
143
		try {
144
			$this->config = $config ?? new WebSocketConfig();
145
			$this->socketUrl = $socketUrl;
146
			$urlParts = parse_url($this->socketUrl);
147
148
			$this->config->setScheme($urlParts['scheme']);
149
			$this->config->setHost($urlParts['host']);
150
			$this->config->setUser($urlParts);
151
			$this->config->setPassword($urlParts);
152
			$this->config->setPort($urlParts);
153
154
			$pathWithQuery = $this->getPathWithQuery($urlParts);
155
			$hostUri = $this->getHostUri($this->config);
156
157
			$context = $this->getStreamContext();
158
			if ($this->config->hasProxy()) {
159
				$this->socket = $this->proxy();
160
			} else {
161
				$this->socket = @stream_socket_client(
162
					$hostUri . ':' . $this->config->getPort(),
163
					$errno,
164
					$errstr,
165
					$this->config->getTimeout(),
166
					STREAM_CLIENT_CONNECT,
167
					$context
168
				);
169
			}
170
171
			if ($this->socket === false) {
172
				throw new ConnectionException(
173
					"Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
0 ignored issues
show
Comprehensibility Best Practice introduced by
The variable $errno does not seem to be defined for all execution paths leading up to this point.
Loading history...
Comprehensibility Best Practice introduced by
The variable $errstr does not seem to be defined for all execution paths leading up to this point.
Loading history...
174
					CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
175
				);
176
			}
177
178
			stream_set_timeout($this->socket, $this->config->getTimeout());
179
180
			$key = $this->generateKey();
181
			$headers = [
182
				'Host' => $this->config->getHost() . ':' . $this->config->getPort(),
183
				'User-Agent' => 'Easy-Http/' . self::VERSION . ' (PHP/' . PHP_VERSION . ')',
184
				'Connection' => 'Upgrade',
185
				'Upgrade' => 'WebSocket',
186
				'Sec-WebSocket-Key' => $key,
187
				'Sec-Websocket-Version' => '13',
188
			];
189
190
			if ($this->config->getUser() || $this->config->getPassword()) {
191
				$headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
192
			}
193
194
			if (!empty($this->config->getHeaders())) {
195
				$headers = array_merge($headers, $this->config->getHeaders());
196
			}
197
198
			$header = $this->getHeaders($pathWithQuery, $headers);
199
200
			$this->write($header);
201
202
			$this->validateResponse($this->config, $pathWithQuery, $key);
203
			$this->isConnected = true;
204
			$this->onConnection();
205
206
		} catch (\Exception $e) {
207
			if (is_callable($this->onError) && $this->onError) {
208
				call_user_func($this->onError, $this, new WebSocketException(
209
					$e->getMessage(),
210
					$e->getCode(),
211
					$e
212
				));
213
			}
214
		}
215
	}
216
217
	/**
218
	 * Reconnect to the Web Socket server
219
	 *
220
	 * @throws \Exception
221
	 * @return void
222
	 */
223
	public function reconnect(): void
224
	{
225
		if ($this->isConnected) {
226
			$this->close();
227
		}
228
229
		$this->connect($this->socketUrl, $this->config);
230
	}
231
232
	/**
233
	 * @return void
234
	 * @throws WebSocketException|\Exception
235
	 */
236
	private function onConnection(): void
237
	{
238
		if (is_callable($this->onOpen) && $this->onOpen) {
239
			call_user_func($this->onOpen, $this);
240
		}
241
242
		while ($this->isConnected()) {
243
			if (is_callable($this->onWhile) && $this->onWhile) {
244
				call_user_func($this->onWhile, $this);
245
			}
246
247
			if (is_string(($message = $this->receive()))) {
248
				if (is_callable($this->onMessage) && $this->onMessage) {
249
					call_user_func($this->onMessage, $this, $message);
250
				}
251
			}
252
		}
253
254
		if (is_callable($this->onClose) && $this->onClose) {
255
			call_user_func($this->onClose, $this, $this->closeStatus);
256
		}
257
	}
258
259
	/**
260
	 * Init a proxy connection
261
	 *
262
	 * @return resource|false
263
	 * @throws \InvalidArgumentException
264
	 * @throws ConnectionException
265
	 */
266
	private function proxy()
267
	{
268
		$sock = @stream_socket_client(
269
			WscCommonsContract::TCP_SCHEME . $this->config->getProxyIp() . ':' . $this->config->getProxyPort(),
270
			$errno,
271
			$errstr,
272
			$this->config->getTimeout(),
273
			STREAM_CLIENT_CONNECT,
274
			$this->getStreamContext()
275
		);
276
277
		$write = "CONNECT {$this->config->getProxyIp()}:{$this->config->getProxyPort()} HTTP/1.1\r\n";
278
		$auth = $this->config->getProxyAuth();
279
280
		if ($auth !== NULL) {
281
			$write .= "Proxy-Authorization: Basic {$auth}\r\n";
282
		}
283
284
		$write .= "\r\n";
285
		fwrite($sock, $write);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fwrite() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

285
		fwrite(/** @scrutinizer ignore-type */ $sock, $write);
Loading history...
286
		$resp = fread($sock, 1024);
0 ignored issues
show
Bug introduced by
It seems like $sock can also be of type false; however, parameter $stream of fread() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

286
		$resp = fread(/** @scrutinizer ignore-type */ $sock, 1024);
Loading history...
287
288
		if (preg_match(self::PROXY_MATCH_RESP, $resp) === 1) {
289
			return $sock;
290
		}
291
292
		throw new ConnectionException('Failed to connect to the host via proxy');
293
	}
294
295
	/**
296
	 * @return mixed
297
	 * @throws \InvalidArgumentException
298
	 */
299
	private function getStreamContext(): mixed
300
	{
301
		if ($this->config->getContext() !== null) {
302
			// Suppress the error since we'll catch it below
303
			if (@get_resource_type($this->config->getContext()) === 'stream-context') {
304
				return $this->config->getContext();
305
			}
306
307
			throw new \InvalidArgumentException(
308
				'Stream context is invalid',
309
				CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
310
			);
311
		}
312
313
		return stream_context_create($this->config->getContextOptions());
314
	}
315
316
	/**
317
	 * @param mixed $urlParts
318
	 * @return string
319
	 */
320
	private function getPathWithQuery(mixed $urlParts): string
321
	{
322
		$path = $urlParts['path'] ?? '/';
323
		$query = $urlParts['query'] ?? '';
324
		$fragment = $urlParts['fragment'] ?? '';
325
		$pathWithQuery = $path;
326
327
		if (!empty($query)) {
328
			$pathWithQuery .= '?' . $query;
329
		}
330
331
		if (!empty($fragment)) {
332
			$pathWithQuery .= '#' . $fragment;
333
		}
334
335
		return $pathWithQuery;
336
	}
337
338
	/**
339
	 * @param string $pathWithQuery
340
	 * @param array $headers
341
	 * @return string
342
	 */
343
	private function getHeaders(string $pathWithQuery, array $headers): string
344
	{
345
		return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
346
			. implode(
347
				"\r\n",
348
				array_map(
349
					function ($key, $value) {
350
						return "$key: $value";
351
					},
352
					array_keys($headers),
353
					$headers
354
				)
355
			)
356
			. "\r\n\r\n";
357
	}
358
359
	/**
360
	 * @return string
361
	 */
362
	public function getLastOpcode(): string
363
	{
364
		return $this->lastOpcode;
365
	}
366
367
	/**
368
	 * @return int
369
	 */
370
	public function getCloseStatus(): int
371
	{
372
		return $this->closeStatus;
0 ignored issues
show
Bug Best Practice introduced by
The expression return $this->closeStatus could return the type double which is incompatible with the type-hinted return integer. Consider adding an additional type-check to rule them out.
Loading history...
373
	}
374
375
	/**
376
	 * @return bool
377
	 */
378
	public function isConnected(): bool
379
	{
380
		return $this->isConnected;
381
	}
382
383
	/**
384
	 * @param int $timeout
385
	 * @param null $microSecs
0 ignored issues
show
Documentation Bug introduced by
Are you sure the doc-type for parameter $microSecs is correct as it would always require null to be passed?
Loading history...
386
	 * @return void
387
	 */
388
	public function setTimeout(int $timeout, $microSecs = null): void
389
	{
390
		$this->config->setTimeout($timeout);
391
		if ($this->socket && get_resource_type($this->socket) === 'stream') {
0 ignored issues
show
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $resource of get_resource_type() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

391
		if ($this->socket && get_resource_type(/** @scrutinizer ignore-type */ $this->socket) === 'stream') {
Loading history...
392
			stream_set_timeout($this->socket, $timeout, $microSecs);
0 ignored issues
show
Bug introduced by
$microSecs of type null is incompatible with the type integer expected by parameter $microseconds of stream_set_timeout(). ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

392
			stream_set_timeout($this->socket, $timeout, /** @scrutinizer ignore-type */ $microSecs);
Loading history...
Bug introduced by
It seems like $this->socket can also be of type true; however, parameter $stream of stream_set_timeout() does only seem to accept resource, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

392
			stream_set_timeout(/** @scrutinizer ignore-type */ $this->socket, $timeout, $microSecs);
Loading history...
393
		}
394
	}
395
396
	/**
397
	 * Sends message to opened socket connection client->server
398
	 *
399
	 * @param $payload
400
	 * @param string $opcode
401
	 * @throws \Exception
402
	 */
403
	public function send($payload, string $opcode = CommonsContract::EVENT_TYPE_TEXT): void
404
	{
405
		if (!$this->isConnected) {
406
			throw new \Exception(
407
				"Can't send message. Connection is not established.",
408
				CommonsContract::CLIENT_CONNECTION_NOT_ESTABLISHED
409
			);
410
		}
411
412
		if (array_key_exists($opcode, self::$opcodes) === false) {
413
			throw new BadOpcodeException(
414
				sprintf("Bad opcode '%s'.  Try 'text' or 'binary'.", $opcode),
415
				CommonsContract::CLIENT_BAD_OPCODE
416
			);
417
		}
418
419
		$payloadLength = strlen($payload);
420
		$fragmentCursor = 0;
421
422
		while ($payloadLength > $fragmentCursor) {
423
			$subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
424
			$fragmentCursor += $this->config->getFragmentSize();
425
			$final = $payloadLength <= $fragmentCursor;
426
			$this->sendFragment($final, $subPayload, $opcode, true);
427
			$opcode = 'continuation';
428
		}
429
	}
430
431
	/**
432
	 * Receives message client<-server
433
	 *
434
	 * @return string|null
435
	 * @throws \Exception
436
	 */
437
	public function receive(): string|null
438
	{
439
		if (!$this->isConnected && $this->isClosing === false) {
440
			throw new WebSocketException(
441
				"Your unexpectedly disconnected from the server",
442
				CommonsContract::CLIENT_CONNECTION_NOT_ESTABLISHED
443
			);
444
		}
445
446
		$this->hugePayload = '';
447
448
		return $this->receiveFragment();
449
	}
450
451
	/**
452
	 * Tell the socket to close.
453
	 *
454
	 * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
455
	 * @param string $message A closing message, max 125 bytes.
456
	 * @return bool|null|string
457
	 * @throws \Exception
458
	 */
459
	public function close(int $status = 1000, string $message = 'ttfn'): bool|null|string
460
	{
461
		$statusBin = sprintf('%016b', $status);
462
		$statusStr = '';
463
464
		foreach (str_split($statusBin, 8) as $binstr) {
465
			$statusStr .= chr(bindec($binstr));
0 ignored issues
show
Bug introduced by
It seems like bindec($binstr) can also be of type double; however, parameter $codepoint of chr() does only seem to accept integer, maybe add an additional type check? ( Ignorable by Annotation )

If this is a false-positive, you can also ignore this issue in your code via the ignore-type  annotation

465
			$statusStr .= chr(/** @scrutinizer ignore-type */ bindec($binstr));
Loading history...
466
		}
467
468
		$this->send($statusStr . $message, CommonsContract::EVENT_TYPE_CLOSE);
469
		$this->isClosing = true;
470
471
		return $this->receive(); // Receiving a close frame will close the socket now.
472
	}
473
474
	/**
475
	 * @return string
476
	 */
477
	public function getSocketUrl(): string
478
	{
479
		return $this->socketUrl;
480
	}
481
482
	/**
483
	 * @param int $len
484
	 * @return string|null
485
	 * @throws ConnectionException
486
	 */
487
	protected function read(int $len): string|null
488
	{
489
		if ($this->socket && $this->isConnected()) {
490
			return Middleware::stream_read($this->socket, $len);
491
		}
492
493
		return null;
494
	}
495
496
	/**
497
	 * @param string $data
498
	 * @throws ConnectionException
499
	 */
500
	protected function write(string $data): void
501
	{
502
		Middleware::stream_write($this->socket, $data);
503
	}
504
505
	/**
506
	 * Helper to convert a binary to a string of '0' and '1'.
507
	 *
508
	 * @param string $string
509
	 * @return string
510
	 */
511
	protected static function sprintB(string $string): string
512
	{
513
		$return = '';
514
		$strLen = strlen($string);
515
		for ($i = 0; $i < $strLen; $i++) {
516
			$return .= sprintf('%08b', ord($string[$i]));
517
		}
518
519
		return $return;
520
	}
521
522
}