Completed
Push — master ( 6ae2ec...32167a )
by Arthur
01:15
created

WscMain   B

Complexity

Total Complexity 44

Size/Duplication

Total Lines 417
Duplicated Lines 0 %

Coupling/Cohesion

Components 1
Dependencies 4

Importance

Changes 0
Metric Value
wmc 44
lcom 1
cbo 4
dl 0
loc 417
rs 8.8798
c 0
b 0
f 0

How to fix   Complexity   

Complex Class

Complex classes like WscMain often do a lot of different things. To break such a class down, we need to identify a cohesive component within that class. A common approach to find such a component is to look for fields/methods that share the same prefixes, or suffixes. You can also have a look at the cohesion graph to spot any un-connected, or weakly-connected components.

Once you have determined the fields that belong together, you can apply the Extract Class refactoring. If the component makes sense as a sub-class, Extract Subclass is also a candidate, and is often faster.

While breaking up the class, it is a good idea to analyze how other classes use WscMain, and based on these observations, apply Extract Interface, too.

1
<?php
2
3
namespace WSSC\Components;
4
5
use WSSC\Contracts\CommonsContract;
6
use WSSC\Contracts\WscCommonsContract;
7
use WSSC\Exceptions\BadOpcodeException;
8
use WSSC\Exceptions\BadUriException;
9
use WSSC\Exceptions\ConnectionException;
10
11
/**
12
 * Class WscMain
13
 *
14
 * @package WSSC\Components
15
 *
16
 * @property ClientConfig config
17
 */
18
class WscMain implements WscCommonsContract
19
{
20
    use WSClientTrait;
21
22
    /**
23
     * @var resource|bool
24
     */
25
    private $socket;
26
27
    /**
28
     * @var bool
29
     */
30
    private bool $isConnected = false;
0 ignored issues
show
Bug introduced by
This code did not parse for me. Apparently, there is an error somewhere around this line:

Syntax error, unexpected T_STRING, expecting T_FUNCTION or T_CONST
Loading history...
31
32
    /**
33
     * @var bool
34
     */
35
    private bool $isClosing = false;
36
37
    /**
38
     * @var string
39
     */
40
    private string $lastOpcode;
41
42
    /**
43
     * @var float|int
44
     */
45
    private $closeStatus;
46
47
    /**
48
     * @var string|null
49
     */
50
    private ?string $hugePayload;
51
52
    private static array $opcodes = [
53
        CommonsContract::EVENT_TYPE_CONTINUATION => 0,
54
        CommonsContract::EVENT_TYPE_TEXT => 1,
55
        CommonsContract::EVENT_TYPE_BINARY => 2,
56
        CommonsContract::EVENT_TYPE_CLOSE => 8,
57
        CommonsContract::EVENT_TYPE_PING => 9,
58
        CommonsContract::EVENT_TYPE_PONG => 10,
59
    ];
60
61
    protected string $socketUrl = '';
62
    protected ClientConfig $config;
63
64
    /**
65
     * @param ClientConfig $config
66
     * @throws BadUriException
67
     * @throws ConnectionException
68
     */
69
    protected function connect(ClientConfig $config): void
70
    {
71
        $this->config = $config;
72
        $urlParts = parse_url($this->socketUrl);
73
74
        $this->config->setScheme($urlParts['scheme']);
75
        $this->config->setHost($urlParts['host']);
76
        $this->config->setUser($urlParts);
77
        $this->config->setPassword($urlParts);
78
        $this->config->setPort($urlParts);
79
80
        $pathWithQuery = $this->getPathWithQuery($urlParts);
81
        $hostUri = $this->getHostUri($this->config);
82
83
        // Set the stream context options if they're already set in the config
84
        $context = $this->getStreamContext();
85
        if ($this->config->hasProxy()) {
86
            $this->socket = $this->proxy();
87
        } else {
88
            $this->socket = @stream_socket_client(
89
                $hostUri . ':' . $this->config->getPort(),
90
                $errno,
91
                $errstr,
92
                $this->config->getTimeout(),
93
                STREAM_CLIENT_CONNECT,
94
                $context
95
            );
96
        }
97
98
        if ($this->socket === false) {
99
            throw new ConnectionException(
100
                "Could not open socket to \"{$this->config->getHost()}:{$this->config->getPort()}\": $errstr ($errno).",
101
                CommonsContract::CLIENT_COULD_NOT_OPEN_SOCKET
102
            );
103
        }
104
105
        // Set timeout on the stream as well.
106
        stream_set_timeout($this->socket, $this->config->getTimeout());
107
108
        // Generate the WebSocket key.
109
        $key = $this->generateKey();
110
        $headers = [
111
            'Host'                  => $this->config->getHost() . ':' . $this->config->getPort(),
112
            'User-Agent'            => 'websocket-client-php',
113
            'Connection'            => 'Upgrade',
114
            'Upgrade'               => 'WebSocket',
115
            'Sec-WebSocket-Key'     => $key,
116
            'Sec-Websocket-Version' => '13',
117
        ];
118
119
        // Handle basic authentication.
120
        if ($this->config->getUser() || $this->config->getPassword()) {
121
            $headers['authorization'] = 'Basic ' . base64_encode($this->config->getUser() . ':' . $this->config->getPassword()) . "\r\n";
122
        }
123
124
        // Add and override with headers from options.
125
        if (!empty($this->config->getHeaders())) {
126
            $headers = array_merge($headers, $this->config->getHeaders());
127
        }
128
129
        $header = $this->getHeaders($pathWithQuery, $headers);
130
131
        // Send headers.
132
        $this->write($header);
133
134
        // Get server response header
135
        // @todo Handle version switching
136
        $this->validateResponse($this->config, $pathWithQuery, $key);
137
        $this->isConnected = true;
138
    }
139
140
141
    /**
142
     * Init a proxy connection
143
     *
144
     * @return bool|resource
145
     * @throws \InvalidArgumentException
146
     * @throws \WSSC\Exceptions\ConnectionException
147
     */
148
    private function proxy()
149
    {
150
        $sock = @stream_socket_client(
151
            WscCommonsContract::TCP_SCHEME . $this->config->getProxyIp() . ':' . $this->config->getProxyPort(),
152
            $errno,
153
            $errstr,
154
            $this->config->getTimeout(),
155
            STREAM_CLIENT_CONNECT,
156
            $this->getStreamContext()
157
        );
158
        $write = "CONNECT {$this->config->getProxyIp()}:{$this->config->getProxyPort()} HTTP/1.1\r\n";
159
        $auth = $this->config->getProxyAuth();
160
        if ($auth !== NULL) {
161
            $write .= "Proxy-Authorization: Basic {$auth}\r\n";
162
        }
163
        $write .= "\r\n";
164
        fwrite($sock, $write);
165
        $resp = fread($sock, 1024);
166
167
        if (preg_match(self::PROXY_MATCH_RESP, $resp) === 1) {
168
            return $sock;
169
        }
170
171
        throw new ConnectionException('Failed to connect to the host via proxy');
172
    }
173
174
175
    /**
176
     * @return mixed|resource
177
     * @throws \InvalidArgumentException
178
     */
179
    private function getStreamContext()
180
    {
181
        if ($this->config->getContext() !== null) {
182
            // Suppress the error since we'll catch it below
183
            if (@get_resource_type($this->config->getContext()) === 'stream-context') {
184
                return $this->config->getContext();
185
            }
186
187
            throw new \InvalidArgumentException(
188
                'Stream context is invalid',
189
                CommonsContract::CLIENT_INVALID_STREAM_CONTEXT
190
            );
191
        }
192
193
        return stream_context_create($this->config->getContextOptions());
194
    }
195
196
    /**
197
     * @param mixed $urlParts
198
     * @return string
199
     */
200
    private function getPathWithQuery($urlParts): string
201
    {
202
        $path = isset($urlParts['path']) ? $urlParts['path'] : '/';
203
        $query = isset($urlParts['query']) ? $urlParts['query'] : '';
204
        $fragment = isset($urlParts['fragment']) ? $urlParts['fragment'] : '';
205
        $pathWithQuery = $path;
206
        if (!empty($query)) {
207
            $pathWithQuery .= '?' . $query;
208
        }
209
        if (!empty($fragment)) {
210
            $pathWithQuery .= '#' . $fragment;
211
        }
212
213
        return $pathWithQuery;
214
    }
215
216
    /**
217
     * @param string $pathWithQuery
218
     * @param array $headers
219
     * @return string
220
     */
221
    private function getHeaders(string $pathWithQuery, array $headers): string
222
    {
223
        return 'GET ' . $pathWithQuery . " HTTP/1.1\r\n"
224
            . implode(
225
                "\r\n",
226
                array_map(
227
                    function ($key, $value) {
228
                        return "$key: $value";
229
                    },
230
                    array_keys($headers),
231
                    $headers
232
                )
233
            )
234
            . "\r\n\r\n";
235
    }
236
237
    /**
238
     * @return string
239
     */
240
    public function getLastOpcode(): string
241
    {
242
        return $this->lastOpcode;
243
    }
244
245
    /**
246
     * @return int
247
     */
248
    public function getCloseStatus(): int
249
    {
250
        return $this->closeStatus;
251
    }
252
253
    /**
254
     * @return bool
255
     */
256
    public function isConnected(): bool
257
    {
258
        return $this->isConnected;
259
    }
260
261
    /**
262
     * @param int $timeout
263
     * @param null $microSecs
264
     * @return WscMain
265
     */
266
    public function setTimeout(int $timeout, $microSecs = null): WscMain
267
    {
268
        $this->config->setTimeout($timeout);
269
        if ($this->socket && get_resource_type($this->socket) === 'stream') {
270
            stream_set_timeout($this->socket, $timeout, $microSecs);
271
        }
272
273
        return $this;
274
    }
275
276
    /**
277
     * Sends message to opened socket connection client->server
278
     *
279
     * @param $payload
280
     * @param string $opcode
281
     * @throws \InvalidArgumentException
282
     * @throws BadOpcodeException
283
     * @throws BadUriException
284
     * @throws ConnectionException
285
     * @throws \Exception
286
     */
287
    public function send($payload, $opcode = CommonsContract::EVENT_TYPE_TEXT): void
288
    {
289
        if (!$this->isConnected) {
290
            $this->connect();
291
        }
292
        if (array_key_exists($opcode, self::$opcodes) === false) {
293
            throw new BadOpcodeException(
294
                "Bad opcode '$opcode'.  Try 'text' or 'binary'.",
295
                CommonsContract::CLIENT_BAD_OPCODE
296
            );
297
        }
298
        // record the length of the payload
299
        $payloadLength = strlen($payload);
300
301
        $fragmentCursor = 0;
302
        // while we have data to send
303
        while ($payloadLength > $fragmentCursor) {
304
            // get a fragment of the payload
305
            $subPayload = substr($payload, $fragmentCursor, $this->config->getFragmentSize());
306
307
            // advance the cursor
308
            $fragmentCursor += $this->config->getFragmentSize();
309
310
            // is this the final fragment to send?
311
            $final = $payloadLength <= $fragmentCursor;
312
313
            // send the fragment
314
            $this->sendFragment($final, $subPayload, $opcode, true);
315
316
            // all fragments after the first will be marked a continuation
317
            $opcode = 'continuation';
318
        }
319
    }
320
321
    /**
322
     * Receives message client<-server
323
     *
324
     * @return null|string
325
     * @throws \InvalidArgumentException
326
     * @throws BadOpcodeException
327
     * @throws BadUriException
328
     * @throws ConnectionException
329
     * @throws \Exception
330
     */
331
    public function receive(): ?string
332
    {
333
        if (!$this->isConnected) {
334
            $this->connect();
335
        }
336
        $this->hugePayload = '';
337
338
        $response = null;
339
        while ($response === null) {
340
            $response = $this->receiveFragment();
341
        }
342
343
        return $response;
344
    }
345
346
    /**
347
     * Tell the socket to close.
348
     *
349
     * @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
350
     * @param string $message A closing message, max 125 bytes.
351
     * @return bool|null|string
352
     * @throws \InvalidArgumentException
353
     * @throws BadOpcodeException
354
     * @throws BadUriException
355
     * @throws ConnectionException
356
     * @throws \Exception
357
     */
358
    public function close(int $status = 1000, string $message = 'ttfn')
359
    {
360
        $statusBin = sprintf('%016b', $status);
361
        $statusStr = '';
362
363
        foreach (str_split($statusBin, 8) as $binstr) {
364
            $statusStr .= chr(bindec($binstr));
365
        }
366
367
        $this->send($statusStr . $message, CommonsContract::EVENT_TYPE_CLOSE);
368
        $this->isClosing = true;
369
370
        return $this->receive(); // Receiving a close frame will close the socket now.
371
    }
372
373
    /**
374
     * @param $data
375
     * @throws ConnectionException
376
     */
377
    protected function write(string $data): void
378
    {
379
        $written = fwrite($this->socket, $data);
380
381
        if ($written < strlen($data)) {
382
            throw new ConnectionException(
383
                "Could only write $written out of " . strlen($data) . ' bytes.',
384
                CommonsContract::CLIENT_COULD_ONLY_WRITE_LESS
385
            );
386
        }
387
    }
388
389
    /**
390
     * @param int $len
391
     * @return string
392
     * @throws ConnectionException
393
     */
394
    protected function read(int $len): string
395
    {
396
        $data = '';
397
        while (($dataLen = strlen($data)) < $len) {
398
            $buff = fread($this->socket, $len - $dataLen);
399
400
            if ($buff === false) {
401
                $metadata = stream_get_meta_data($this->socket);
402
                throw new ConnectionException(
403
                    'Broken frame, read ' . strlen($data) . ' of stated '
404
                    . $len . ' bytes.  Stream state: '
405
                    . json_encode($metadata),
406
                    CommonsContract::CLIENT_BROKEN_FRAME
407
                );
408
            }
409
410
            if ($buff === '') {
411
                $metadata = stream_get_meta_data($this->socket);
412
                throw new ConnectionException(
413
                    'Empty read; connection dead?  Stream state: ' . json_encode($metadata),
414
                    CommonsContract::CLIENT_EMPTY_READ
415
                );
416
            }
417
            $data .= $buff;
418
        }
419
420
        return $data;
421
    }
422
423
    /**
424
     * Helper to convert a binary to a string of '0' and '1'.
425
     *
426
     * @param $string
427
     * @return string
428
     */
429
    protected static function sprintB(string $string): string
430
    {
431
        $return = '';
432
        $strLen = strlen($string);
433
        for ($i = 0; $i < $strLen; $i++) {
434
            $return .= sprintf('%08b', ord($string[$i]));
435
        }
436
437
        return $return;
438
    }
439
440
    /**
441
     * Sec-WebSocket-Key generator
442
     *
443
     * @return string   the 16 character length key
444
     * @throws \Exception
445
     */
446
    private function generateKey(): string
447
    {
448
        $chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$&/()=[]{}0123456789';
449
        $key = '';
450
        $chLen = strlen($chars);
451
        for ($i = 0; $i < self::KEY_GEN_LENGTH; $i++) {
452
            $key .= $chars[random_int(0, $chLen - 1)];
453
        }
454
455
        return base64_encode($key);
456
    }
457
}
458