• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

sirn-se / websocket-php / 4771910143

pending completion
4771910143

push

github

Sören Jensen
Connect, handshake, HTTP, etc

212 of 212 new or added lines in 5 files covered. (100.0%)

591 of 670 relevant lines covered (88.21%)

14.85 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

93.02
/lib/Client.php
1
<?php
2

3
/**
4
 * Copyright (C) 2014-2023 Textalk/Abicart and contributors.
5
 *
6
 * This file is part of Websocket PHP and is free software under the ISC License.
7
 * License text: https://raw.githubusercontent.com/sirn-se/websocket-php/master/COPYING.md
8
 */
9

10
namespace WebSocket;
11

12
use ErrorException;
13
use InvalidArgumentException;
14
use Phrity\Net\Uri;
15
use Phrity\Net\StreamFactory;
16
use Phrity\Util\ErrorHandler;
17
use Psr\Http\Message\UriInterface;
18
use Psr\Log\{
19
    LoggerAwareInterface,
20
    LoggerAwareTrait,
21
    LoggerInterface,
22
    NullLogger
23
};
24
use WebSocket\Message\Factory;
25
use WebSocket\Message\Message;
26

27
class Client implements LoggerAwareInterface
28
{
29
    use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger)
30
    use OpcodeTrait;
31

32
    // Default options
33
    protected static $default_options = [
34
        'context'       => null,
35
        'filter'        => ['text', 'binary'], // @deprecated
36
        'fragment_size' => 4096,
37
        'headers'       => [],
38
        'logger'        => null,
39
        'masked'        => true,
40
        'origin'        => null, // @deprecated
41
        'persistent'    => false,
42
        'return_obj'    => false, // @deprecated
43
        'timeout'       => 5,
44
    ];
45

46
    private $socket_uri;
47
    private $connection;
48
    private $options = [];
49
    private $listen = false;
50
    private $last_opcode = null;
51
    private $stream_factory;
52
    private $message_factory;
53
    private $handshake_response;
54

55

56
    /* ---------- Magic methods ------------------------------------------------------ */
57

58
    /**
59
     * @param UriInterface|string $uri     A ws/wss-URI
60
     * @param array               $options
61
     *   Associative array containing:
62
     *   - context:       Set the stream context. Default: empty context
63
     *   - timeout:       Set the socket timeout in seconds.  Default: 5
64
     *   - fragment_size: Set framgemnt size.  Default: 4096
65
     *   - headers:       Associative array of headers to set/override.
66
     */
67
    public function __construct($uri, array $options = [])
68
    {
69
        $this->socket_uri = $this->parseUri($uri);
38✔
70
        $this->options = array_merge(self::$default_options, $options);
36✔
71
        $this->setLogger($this->options['logger'] ?: new NullLogger());
36✔
72
        $this->setStreamFactory(new StreamFactory());
36✔
73
        $this->message_factory = new Factory();
36✔
74
    }
75

76
    /**
77
     * Get string representation of instance.
78
     * @return string String representation.
79
     */
80
    public function __toString(): string
81
    {
82
        return sprintf(
1✔
83
            "%s(%s)",
1✔
84
            get_class($this),
1✔
85
            $this->getName() ?: 'closed'
1✔
86
        );
1✔
87
    }
88

89

90
    /* ---------- Client option functions -------------------------------------------- */
91

92
    /**
93
     * Set timeout.
94
     * @param int $timeout Timeout in seconds.
95
     */
96
    public function setTimeout(int $timeout): void
97
    {
98
        $this->options['timeout'] = $timeout;
2✔
99
        if (!$this->isConnected()) {
2✔
100
            return;
1✔
101
        }
102
        $this->connection->setTimeout($timeout);
1✔
103
        $this->connection->setOptions($this->options);
1✔
104
    }
105

106
    /**
107
     * Set fragmentation size.
108
     * @param int $fragment_size Fragment size in bytes.
109
     * @return self.
110
     */
111
    public function setFragmentSize(int $fragment_size): self
112
    {
113
        $this->options['fragment_size'] = $fragment_size;
3✔
114
        if (!$this->isConnected()) {
3✔
115
            return $this;
2✔
116
        }
117
        $this->connection->setOptions($this->options);
1✔
118
        return $this;
1✔
119
    }
120

121
    /**
122
     * Get fragmentation size.
123
     * @return int $fragment_size Fragment size in bytes.
124
     */
125
    public function getFragmentSize(): int
126
    {
127
        return $this->options['fragment_size'];
3✔
128
    }
129

130
    public function setStreamFactory(StreamFactory $stream_factory)
131
    {
132
        $this->stream_factory = $stream_factory;
36✔
133
    }
134

135

136
    /* ---------- Connection operations ---------------------------------------------- */
137

138
    /**
139
     * Send text message.
140
     * @param string $payload Content as string.
141
     */
142
    public function text(string $payload): void
143
    {
144
        $this->send($payload);
1✔
145
    }
146

147
    /**
148
     * Send binary message.
149
     * @param string $payload Content as binary string.
150
     */
151
    public function binary(string $payload): void
152
    {
153
        $this->send($payload, 'binary');
1✔
154
    }
155

156
    /**
157
     * Send ping.
158
     * @param string $payload Optional text as string.
159
     */
160
    public function ping(string $payload = ''): void
161
    {
162
        $this->send($payload, 'ping');
1✔
163
    }
164

165
    /**
166
     * Send unsolicited pong.
167
     * @param string $payload Optional text as string.
168
     */
169
    public function pong(string $payload = ''): void
170
    {
171
        $this->send($payload, 'pong');
1✔
172
    }
173

174
    /**
175
     * Send message.
176
     * @param Message|string $payload Message to send, as Meessage instance or string.
177
     * @param string $opcode Opcode to use, default: 'text'.
178
     * @param bool $masked If message should be masked default: true.
179
     */
180
    public function send($payload, string $opcode = 'text', ?bool $masked = null): void
181
    {
182
        $masked = is_null($masked) ? true : $masked;
28✔
183
        if (!$this->isConnected()) {
28✔
184
            $this->connect();
28✔
185
        }
186
        if ($payload instanceof Message) {
22✔
187
            $this->connection->pushMessage($payload, $masked);
×
188
            return;
×
189
        }
190
        if (!in_array($opcode, array_keys(self::$opcodes))) {
22✔
191
            $warning = "Bad opcode '{$opcode}'.  Try 'text' or 'binary'.";
1✔
192
            $this->logger->warning($warning);
1✔
193
            throw new BadOpcodeException($warning);
1✔
194
        }
195

196
        $message = $this->message_factory->create($opcode, $payload);
21✔
197
        $this->connection->pushMessage($message, $masked);
21✔
198
    }
199

200
    /**
201
     * Tell the socket to close.
202
     * @param integer $status  http://tools.ietf.org/html/rfc6455#section-7.4
203
     * @param string  $message A closing message, max 125 bytes.
204
     */
205
    public function close(int $status = 1000, string $message = 'ttfn'): void
206
    {
207
        if (!$this->isConnected()) {
3✔
208
            return;
1✔
209
        }
210
        $this->connection->close($status, $message);
2✔
211
    }
212

213
    /**
214
     * Connect to server and perform upgrade.
215
     * @throws ConnectionException On failed connection
216
     */
217
    public function connect(): void
218
    {
219
        $this->connection = null;
33✔
220

221
        $host_uri = (new Uri())
33✔
222
            ->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp')
33✔
223
            ->withHost($this->socket_uri->getHost(Uri::IDNA))
33✔
224
            ->withPort($this->socket_uri->getPort(Uri::REQUIRE_PORT));
33✔
225

226
        $http_uri = (new Uri())
33✔
227
            ->withPath($this->socket_uri->getPath(), Uri::ABSOLUTE_PATH)
33✔
228
            ->withQuery($this->socket_uri->getQuery());
33✔
229

230
        $context = $this->parseContext();
33✔
231
        $persistent = $this->options['persistent'] === true;
32✔
232
        $stream = null;
32✔
233

234
        try {
235
            $client = $this->stream_factory->createSocketClient($host_uri);
32✔
236
            $client->setPersistent($persistent);
32✔
237
            $client->setTimeout($this->options['timeout']);
32✔
238
            $client->setContext($context);
32✔
239
            $stream = $client->connect();
32✔
240

241
            if (!$stream) {
31✔
242
                throw new \RuntimeException('No socket');
31✔
243
            }
244
        } catch (\RuntimeException $e) {
1✔
245
            $error = "Could not open socket to \"{$host_uri}\": {$e->getMessage()} ({$e->getCode()}).";
1✔
246
            $this->logger->error($error, []);
1✔
247
            throw new ConnectionException($error, 0, [], $e);
1✔
248
        }
249

250
        $this->connection = new Connection($stream, $this->options);
31✔
251
        $this->connection->setLogger($this->logger);
31✔
252
        if (!$this->isConnected()) {
31✔
253
            $error = "Invalid stream on \"{$host_uri}\".";
×
254
            $this->logger->error($error);
×
255
            throw new ConnectionException($error);
×
256
        }
257

258
        if (!$persistent || $this->connection->tell() == 0) {
31✔
259
            // Set timeout on the stream as well.
260
            $this->connection->setTimeout($this->options['timeout']);
31✔
261

262
            $this->handshake_response = $this->performHandshake($host_uri, $http_uri);
31✔
263
        }
264

265
        $this->logger->info("Client connected to {$this->socket_uri}");
27✔
266
    }
267

268
    /**
269
     * Disconnect from server.
270
     */
271
    public function disconnect(): void
272
    {
273
        if ($this->isConnected()) {
1✔
274
            $this->connection->disconnect();
1✔
275
        }
276
    }
277

278
    /**
279
     * Receive message.
280
     * Note that this operation will block reading.
281
     * @return mixed Message, text or null depending on settings.
282
     */
283
    public function receive()
284
    {
285
        $filter = $this->options['filter'];
12✔
286
        $return_obj = $this->options['return_obj'];
12✔
287

288
        if (!$this->isConnected()) {
12✔
289
            $this->connect();
6✔
290
        }
291

292
        while (true) {
12✔
293
            $message = $this->connection->pullMessage();
12✔
294
            $opcode = $message->getOpcode();
9✔
295
            if (in_array($opcode, $filter)) {
9✔
296
                $this->last_opcode = $opcode;
8✔
297
                $return = $return_obj ? $message : $message->getContent();
8✔
298
                break;
8✔
299
            } elseif ($opcode == 'close') {
2✔
300
                $this->last_opcode = null;
1✔
301
                $return = $return_obj ? $message : null;
1✔
302
                break;
1✔
303
            }
304
        }
305
        return $return;
9✔
306
    }
307

308

309
    /* ---------- Connection functions ----------------------------------------------- */
310

311
    /**
312
     * Get last received opcode.
313
     * @return string|null Opcode.
314
     */
315
    public function getLastOpcode(): ?string
316
    {
317
        return $this->last_opcode;
5✔
318
    }
319

320
    /**
321
     * Get last received opcode.
322
     * @return string|null Opcode.
323
     */
324
    public function getHandshakeResponse(): ?\WebSocket\Http\Response
325
    {
326
        return $this->connection ? $this->handshake_response : null;
×
327
    }
328

329
    /**
330
     * Get close status on connection.
331
     * @return int|null Close status.
332
     */
333
    public function getCloseStatus(): ?int
334
    {
335
        return $this->connection ? $this->connection->getCloseStatus() : null;
5✔
336
    }
337

338
    /**
339
     * If Client has active connection.
340
     * @return bool True if active connection.
341
     */
342
    public function isConnected(): bool
343
    {
344
        return $this->connection && $this->connection->isConnected();
35✔
345
    }
346

347
    /**
348
     * Get name of local socket, or null if not connected.
349
     * @return string|null
350
     */
351
    public function getName(): ?string
352
    {
353
        return $this->isConnected() ? $this->connection->getName() : null;
2✔
354
    }
355

356
    /**
357
     * Get name of remote socket, or null if not connected.
358
     * @return string|null
359
     */
360
    public function getRemoteName(): ?string
361
    {
362
        return $this->isConnected() ? $this->connection->getRemoteName() : null;
3✔
363
    }
364

365
    /**
366
     * Get name of remote socket, or null if not connected.
367
     * @return string|null
368
     * @deprecated Will be removed in future version, use getPeer() instead.
369
     */
370
    public function getPier(): ?string
371
    {
372
        trigger_error(
1✔
373
            'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
1✔
374
            E_USER_DEPRECATED
1✔
375
        );
1✔
376
        return $this->getRemoteName();
1✔
377
    }
378

379

380
    /* ---------- Helper functions --------------------------------------------------- */
381

382
    /**
383
     * Perform upgrade handshake on new connections.
384
     * @throws ConnectionException On failed handshake
385
     */
386
    protected function performHandshake($host_uri, $http_uri): \WebSocket\Http\Response
387
    {
388
        // Generate the WebSocket key.
389
        $key = $this->generateKey();
31✔
390

391
        $request = new \WebSocket\Http\Request('GET', $http_uri);
31✔
392
        $response = new \WebSocket\Http\Response();
31✔
393

394
        $request = $request
31✔
395
            ->withHeader('Host', $host_uri->getAuthority())
31✔
396
            ->withHeader('User-Agent', 'websocket-client-php')
31✔
397
            ->withHeader('Connection', 'Upgrade')
31✔
398
            ->withHeader('Upgrade', 'websocket')
31✔
399
            ->withHeader('Sec-WebSocket-Key', $key)
31✔
400
            ->withHeader('Sec-WebSocket-Version', '13');
31✔
401

402
        // Handle basic authentication.
403
        if ($userinfo = $this->socket_uri->getUserInfo()) {
31✔
404
            $request = $request->withHeader('authorization', 'Basic ' . base64_encode($userinfo));
1✔
405
        }
406

407
        // Deprecated way of adding origin (use headers instead).
408
        if (isset($this->options['origin'])) {
31✔
409
            $request = $request->withHeader('origin', $this->options['origin']);
1✔
410
        }
411

412
        // Add and override with headers from options.
413
        foreach ($this->options['headers'] as $name => $content) {
31✔
414
            $request = $request->withHeader($name, $content);
1✔
415
        }
416

417
        try {
418
            $this->connection->pushHttp($request);
31✔
419
            $response = $this->connection->pullHttp($response);
31✔
420
        } catch (\RuntimeException $e) {
2✔
421
            $error = 'Client handshake error';
2✔
422
            $this->logger->error($error);
2✔
423
            throw new ConnectionException($error, $e->getCode());
2✔
424
        }
425

426
        if ($response->getStatusCode() != 101) {
29✔
427
            $error = "Invalid status code {$response->getStatusCode()}.";
×
428
            $this->logger->error($error);
×
429
            throw new ConnectionException($error);
×
430
        }
431

432
        if (empty($response->getHeaderLine('Sec-WebSocket-Accept'))) {
29✔
433
            $error = sprintf(
1✔
434
                "Connection to '%s' failed: Server sent invalid upgrade response.",
1✔
435
                (string)$this->socket_uri
1✔
436
            );
1✔
437
            $this->logger->error($error);
1✔
438
            throw new ConnectionException($error);
1✔
439
        }
440

441
        $response_key = trim($response->getHeaderLine('Sec-WebSocket-Accept'));
28✔
442
        $expected_key = base64_encode(
28✔
443
            pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
28✔
444
        );
28✔
445

446
        if ($response_key !== $expected_key) {
28✔
447
            $error = 'Server sent bad upgrade response.';
1✔
448
            $this->logger->error($error);
1✔
449
            throw new ConnectionException($error);
1✔
450
        }
451
        return $response;
27✔
452
    }
453

454
    /**
455
     * Generate a random string for WebSocket key.
456
     * @return string Random string
457
     */
458
    protected function generateKey(): string
459
    {
460
        $key = '';
31✔
461
        for ($i = 0; $i < 16; $i++) {
31✔
462
            $key .= chr(rand(33, 126));
31✔
463
        }
464
        return base64_encode($key);
31✔
465
    }
466

467
    /**
468
     * Ensure URI insatnce to use in client.
469
     * @param UriInterface|string $uri A ws/wss-URI
470
     * @return Uri
471
     * @throws BadUriException On invalid URI
472
     */
473
    protected function parseUri($uri): UriInterface
474
    {
475
        if ($uri instanceof Uri) {
38✔
476
            $uri_instance = $uri;
4✔
477
        } elseif ($uri instanceof UriInterface) {
34✔
478
            $uri_instance = new Uri("{$uri}");
×
479
        } elseif (is_string($uri)) {
34✔
480
            try {
481
                $uri_instance = new Uri($uri);
33✔
482
            } catch (InvalidArgumentException $e) {
1✔
483
                throw new BadUriException("Invalid URI '{$uri}' provided.");
33✔
484
            }
485
        } else {
486
            throw new BadUriException("Provided URI must be a UriInterface or string.");
1✔
487
        }
488
        if (!in_array($uri_instance->getScheme(), ['ws', 'wss'])) {
36✔
489
            throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'.");
1✔
490
        }
491
        if (!$uri_instance->getHost()) {
36✔
492
            throw new BadUriException("Invalid URI host.");
×
493
        }
494
        return $uri_instance;
36✔
495
    }
496

497
    /**
498
     * Ensure context in correct format.
499
     * @return array
500
     * @throws InvalidArgumentException On invalid context
501
     */
502
    protected function parseContext(): array
503
    {
504
        if (empty($this->options['context'])) {
33✔
505
            return [];
31✔
506
        }
507
        if (is_array($this->options['context'])) {
2✔
508
            return $this->options['context'];
×
509
        }
510
        if (
511
            is_resource($this->options['context'])
2✔
512
            && get_resource_type($this->options['context']) === 'stream-context'
2✔
513
        ) {
514
            return stream_context_get_options($this->options['context']);
1✔
515
        }
516
        $error = "Stream context in \$options['context'] isn't a valid context.";
1✔
517
        $this->logger->error($error);
1✔
518
        throw new \InvalidArgumentException($error);
1✔
519
    }
520
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2025 Coveralls, Inc