Skip to content

Commit 286aad2

Browse files
author
mateu
committed
Client
1 parent 3b2649e commit 286aad2

File tree

1 file changed

+396
-0
lines changed

1 file changed

+396
-0
lines changed

src/Client.php

Lines changed: 396 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,396 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bloatless\WebSocket;
6+
7+
/**
8+
* Simple WebSocket client.
9+
*
10+
* @author Simon Samtleben <foo@bloatless.org>
11+
* @version 2.0
12+
*/
13+
class Client
14+
{
15+
/**
16+
* @var string $host
17+
*/
18+
private $host;
19+
20+
/**
21+
* @var int $port
22+
*/
23+
private $port;
24+
25+
/**
26+
* @var string $path
27+
*/
28+
private $path;
29+
30+
/**
31+
* @var string $origin
32+
*/
33+
private $origin;
34+
35+
/**
36+
* @var resource $socket
37+
*/
38+
private $socket = null;
39+
40+
/**
41+
* @var bool $connected
42+
*/
43+
private $connected = false;
44+
45+
/**
46+
* @var int $timeout_seconds
47+
*/
48+
private $timeout_seconds = 0;
49+
50+
/**
51+
* @var int $timeout_microseconds;
52+
*/
53+
private $timeout_microseconds = 10000;
54+
55+
56+
public function __destruct()
57+
{
58+
$this->disconnect();
59+
}
60+
61+
/**
62+
* Set Timeouts
63+
*
64+
* @param int $timeout_seconds
65+
* @param int $timeout_microseconds
66+
* @return void
67+
*/
68+
public function setTimeout( int $timeout_seconds = 0, int $timeout_microseconds = 10000 ) : void
69+
{
70+
if( $timeout_seconds >= 0 ) {
71+
$this->timeout_seconds = $timeout_seconds;
72+
}
73+
if( $timeout_microseconds >= 0 ) {
74+
$this->timeout_microseconds = $timeout_microseconds;
75+
}
76+
}
77+
78+
/**
79+
* Sends data to remote server.
80+
*
81+
* @param string $data
82+
* @param string $type
83+
* @param bool $masked
84+
* @return bool
85+
*/
86+
public function sendData(string $data, string $type = 'text', bool $masked = true): bool
87+
{
88+
if ($this->connected === false) {
89+
trigger_error("Not connected: ".print_r( stream_get_meta_data( $this->socket ), true ), E_USER_WARNING);
90+
return false;
91+
}
92+
if (!is_string($data)) {
93+
trigger_error("Not a string data was given.", E_USER_WARNING);
94+
return false;
95+
}
96+
if (strlen($data) === 0) {
97+
return false;
98+
}
99+
$res = @fwrite($this->socket, $this->hybi10Encode($data, $type, $masked));
100+
if ($res === 0 || $res === false) {
101+
return false;
102+
}
103+
$buffer = ' ';
104+
while ($buffer !== '') {
105+
$buffer = fread($this->socket, 512);// drop?
106+
}
107+
108+
return true;
109+
}
110+
111+
/**
112+
* Connects to a websocket server.
113+
*
114+
* @param string $host
115+
* @param int $port
116+
* @param string $path
117+
* @param string $origin
118+
* @return bool
119+
*/
120+
public function connect(string $host, int $port, string $path, string $origin = ''): bool
121+
{
122+
$this->host = $host;
123+
$this->port = $port;
124+
$this->path = $path;
125+
$this->origin = $origin;
126+
127+
$key = base64_encode($this->generateRandomString(16, false, true));
128+
$header = "GET " . $path . " HTTP/1.1\r\n";
129+
$header .= "Host: " . $host . ":" . $port . "\r\n";
130+
$header .= "Upgrade: websocket\r\n";
131+
$header .= "Connection: Upgrade\r\n";
132+
$header .= "Sec-WebSocket-Key: " . $key . "\r\n";
133+
if (!empty($origin)) {
134+
$header .= "Sec-WebSocket-Origin: " . $origin . "\r\n";
135+
}
136+
$header .= "Sec-WebSocket-Version: 13\r\n\r\n";
137+
138+
$this->socket = fsockopen($host, $port, $errno, $errstr, 2);
139+
if ($this->socket === false) {
140+
return false;
141+
}
142+
143+
socket_set_timeout($this->socket, $this->timeout_seconds, $this->timeout_microseconds );
144+
@fwrite($this->socket, $header);
145+
$response = @fread($this->socket, 1500);
146+
147+
preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', (string)$response, $matches);
148+
149+
if ($matches) {
150+
$keyAccept = trim($matches[1]);
151+
$expectedResponse = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
152+
$this->connected = ($keyAccept === $expectedResponse) ? true : false;
153+
}
154+
155+
return $this->connected;
156+
}
157+
158+
/**
159+
* Checks if connection to webserver is active.
160+
*
161+
* @return bool
162+
*/
163+
public function checkConnection(): bool
164+
{
165+
$this->connected = false;
166+
167+
// send ping:
168+
$data = 'ping?';
169+
@fwrite($this->socket, $this->hybi10Encode($data, 'ping', true));
170+
$response = @fread($this->socket, 300);
171+
if (empty($response)) {
172+
return false;
173+
}
174+
$response = $this->hybi10Decode($response);
175+
if (!is_array($response)) {
176+
return false;
177+
}
178+
if (!isset($response['type']) || $response['type'] !== 'pong') {
179+
return false;
180+
}
181+
$this->connected = true;
182+
183+
return true;
184+
}
185+
186+
187+
/**
188+
* Disconnectes from websocket server.
189+
*
190+
* @return void
191+
*/
192+
public function disconnect(): void
193+
{
194+
$this->connected = false;
195+
is_resource($this->socket) && fclose($this->socket);
196+
}
197+
198+
/**
199+
* Reconnects to previously connected websocket server.
200+
*
201+
* @return void
202+
*/
203+
public function reconnect(): void
204+
{
205+
sleep(10);
206+
$this->connected = false;
207+
fclose($this->socket);
208+
$this->connect($this->host, $this->port, $this->path, $this->origin);
209+
}
210+
211+
/**
212+
* Generates a random string.
213+
*
214+
* @param int $length
215+
* @param bool $addSpaces
216+
* @param bool $addNumbers
217+
* @return string
218+
*/
219+
private function generateRandomString(int $length = 10, bool $addSpaces = true, bool $addNumbers = true): string
220+
{
221+
$characters = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"§$%&/()=[]{}';
222+
$useChars = [];
223+
// select some random chars:
224+
for ($i = 0; $i < $length; $i++) {
225+
$useChars[] = $characters[mt_rand(0, strlen($characters) - 1)];
226+
}
227+
// add spaces and numbers:
228+
if ($addSpaces === true) {
229+
array_push($useChars, ' ', ' ', ' ', ' ', ' ', ' ');
230+
}
231+
if ($addNumbers === true) {
232+
array_push($useChars, rand(0, 9), rand(0, 9), rand(0, 9));
233+
}
234+
shuffle($useChars);
235+
$randomString = trim(implode('', $useChars));
236+
$randomString = substr($randomString, 0, $length);
237+
238+
return $randomString;
239+
}
240+
241+
/**
242+
* Encodes data according to the WebSocket protocol standard.
243+
*
244+
* @param string $payload
245+
* @param string $type
246+
* @param bool $masked
247+
* @return string
248+
*/
249+
private function hybi10Encode(string $payload, string $type = 'text', bool $masked = true): string
250+
{
251+
$frameHead = [];
252+
$payloadLength = strlen($payload);
253+
254+
switch ($type) {
255+
case 'text':
256+
// first byte indicates FIN, Text-Frame (10000001):
257+
$frameHead[0] = 129;
258+
break;
259+
260+
case 'close':
261+
// first byte indicates FIN, Close Frame(10001000):
262+
$frameHead[0] = 136;
263+
break;
264+
265+
case 'ping':
266+
// first byte indicates FIN, Ping frame (10001001):
267+
$frameHead[0] = 137;
268+
break;
269+
270+
case 'pong':
271+
// first byte indicates FIN, Pong frame (10001010):
272+
$frameHead[0] = 138;
273+
break;
274+
}
275+
276+
// set mask and payload length (using 1, 3 or 9 bytes)
277+
if ($payloadLength > 65535) {
278+
$payloadLengthBin = str_split(sprintf('%064b', $payloadLength), 8);
279+
$frameHead[1] = ($masked === true) ? 255 : 127;
280+
for ($i = 0; $i < 8; $i++) {
281+
$frameHead[$i + 2] = bindec($payloadLengthBin[$i]);
282+
}
283+
// most significant bit MUST be 0 (close connection if frame too big)
284+
if ($frameHead[2] > 127) {
285+
$this->disconnect();
286+
throw new \RuntimeException('Invalid payload. Could not encode frame.');
287+
}
288+
} elseif ($payloadLength > 125) {
289+
$payloadLengthBin = str_split(sprintf('%016b', $payloadLength), 8);
290+
$frameHead[1] = ($masked === true) ? 254 : 126;
291+
$frameHead[2] = bindec($payloadLengthBin[0]);
292+
$frameHead[3] = bindec($payloadLengthBin[1]);
293+
} else {
294+
$frameHead[1] = ($masked === true) ? $payloadLength + 128 : $payloadLength;
295+
}
296+
297+
// convert frame-head to string:
298+
foreach (array_keys($frameHead) as $i) {
299+
$frameHead[$i] = chr($frameHead[$i]);
300+
}
301+
if ($masked === true) {
302+
// generate a random mask:
303+
$mask = [];
304+
for ($i = 0; $i < 4; $i++) {
305+
$mask[$i] = chr(rand(0, 255));
306+
}
307+
308+
$frameHead = array_merge($frameHead, $mask);
309+
}
310+
$frame = implode('', $frameHead);
311+
312+
// append payload to frame:
313+
for ($i = 0; $i < $payloadLength; $i++) {
314+
$frame .= ($masked === true) ? $payload[$i] ^ $mask[$i % 4] : $payload[$i];
315+
}
316+
317+
return $frame;
318+
}
319+
320+
/**
321+
* Decodes a received frame/sting according to the WebSocket protocol standards.
322+
*
323+
* @param string $data
324+
* @return array
325+
*/
326+
private function hybi10Decode(string $data): array
327+
{
328+
$unmaskedPayload = '';
329+
$decodedData = [];
330+
331+
// estimate frame type:
332+
$firstByteBinary = sprintf('%08b', ord($data[0]));
333+
$secondByteBinary = sprintf('%08b', ord($data[1]));
334+
$opcode = bindec(substr($firstByteBinary, 4, 4));
335+
$isMasked = ($secondByteBinary[0] == '1') ? true : false;
336+
$payloadLength = ord($data[1]) & 127;
337+
338+
switch ($opcode) {
339+
// text frame:
340+
case 1:
341+
$decodedData['type'] = 'text';
342+
break;
343+
case 2:
344+
$decodedData['type'] = 'binary';
345+
break;
346+
// connection close frame:
347+
case 8:
348+
$decodedData['type'] = 'close';
349+
break;
350+
// ping frame:
351+
case 9:
352+
$decodedData['type'] = 'ping';
353+
break;
354+
// pong frame:
355+
case 10:
356+
$decodedData['type'] = 'pong';
357+
break;
358+
default:
359+
throw new \RuntimeException('Could not decode frame. Invalid type.');
360+
}
361+
362+
if ($payloadLength === 126) {
363+
$mask = substr($data, 4, 4);
364+
$payloadOffset = 8;
365+
$dataLength = bindec(sprintf('%08b', ord($data[2])) . sprintf('%08b', ord($data[3]))) + $payloadOffset;
366+
} elseif ($payloadLength === 127) {
367+
$mask = substr($data, 10, 4);
368+
$payloadOffset = 14;
369+
$tmp = '';
370+
for ($i = 0; $i < 8; $i++) {
371+
$tmp .= sprintf('%08b', ord($data[$i + 2]));
372+
}
373+
$dataLength = bindec($tmp) + $payloadOffset;
374+
unset($tmp);
375+
} else {
376+
$mask = substr($data, 2, 4);
377+
$payloadOffset = 6;
378+
$dataLength = $payloadLength + $payloadOffset;
379+
}
380+
381+
if ($isMasked === true) {
382+
for ($i = $payloadOffset; $i < $dataLength; $i++) {
383+
$j = $i - $payloadOffset;
384+
if (isset($data[$i])) {
385+
$unmaskedPayload .= $data[$i] ^ $mask[$j % 4];
386+
}
387+
}
388+
$decodedData['payload'] = $unmaskedPayload;
389+
} else {
390+
$payloadOffset = $payloadOffset - 4;
391+
$decodedData['payload'] = substr($data, $payloadOffset);
392+
}
393+
394+
return $decodedData;
395+
}
396+
}

0 commit comments

Comments
 (0)