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

sirn-se / websocket-php / 5608975860

pending completion
5608975860

push

github

Sören Jensen
Middleware support

90 of 90 new or added lines in 8 files covered. (100.0%)

245 of 671 relevant lines covered (36.51%)

1.27 hits per line

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

0.0
/src/Client.php
1
<?php
2

3
/**
4
 * Copyright (C) 2014-2023 Textalk 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 InvalidArgumentException;
13
use Phrity\Net\{
14
    StreamFactory,
15
    Uri
16
};
17
use Psr\Http\Message\UriInterface;
18
use Psr\Log\{
19
    LoggerAwareInterface,
20
    LoggerAwareTrait,
21
    NullLogger
22
};
23
use Throwable;
24
use WebSocket\Http\{
25
    Request,
26
    Response
27
};
28
use WebSocket\Message\{
29
    Message,
30
    Binary,
31
    Close,
32
    Ping,
33
    Pong,
34
    Text
35
};
36
use WebSocket\Middleware\{
37
    CloseHandler,
38
    PingResponder
39
};
40

41
/**
42
 * WebSocket\Client class.
43
 * Entry class for WebSocket client.
44
 */
45
class Client implements LoggerAwareInterface
46
{
47
    use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger)
48
    use OpcodeTrait;
49

50
    // Default options
51
    protected static $default_options = [
52
        'context'       => null,
53
        'fragment_size' => 4096,
54
        'headers'       => [],
55
        'logger'        => null,
56
        'masked'        => true,
57
        'persistent'    => false,
58
        'timeout'       => 5,
59
    ];
60

61
    private $socket_uri;
62
    private $connection;
63
    private $options = [];
64
    private $listen = false;
65
    private $last_opcode = null;
66
    private $streamFactory;
67
    private $handshakeResponse;
68

69

70
    /* ---------- Magic methods ------------------------------------------------------------------------------------ */
71

72
    /**
73
     * @param UriInterface|string $uri A ws/wss-URI
74
     * @param array $options
75
     *   Associative array containing:
76
     *   - context:       Set the stream context. Default: empty context
77
     *   - timeout:       Set the socket timeout in seconds.  Default: 5
78
     *   - fragment_size: Set framgemnt size.  Default: 4096
79
     *   - headers:       Associative array of headers to set/override.
80
     */
81
    public function __construct($uri, array $options = [])
82
    {
83
        $this->socket_uri = $this->parseUri($uri);
×
84
        $this->options = array_merge(self::$default_options, $options);
×
85
        $this->setLogger($this->options['logger'] ?: new NullLogger());
×
86
        $this->setStreamFactory(new StreamFactory());
×
87
    }
88

89
    /**
90
     * Get string representation of instance.
91
     * @return string String representation.
92
     */
93
    public function __toString(): string
94
    {
95
        return sprintf(
×
96
            "%s(%s)",
×
97
            get_class($this),
×
98
            $this->getName() ?: 'closed'
×
99
        );
×
100
    }
101

102

103
    /* ---------- Configuration ------------------------------------------------------------------------------------ */
104

105
    /**
106
     * Set stream factory to use.
107
     * @param StreamFactory $streamFactory.
108
     */
109
    public function setStreamFactory(StreamFactory $streamFactory): void
110
    {
111
        $this->streamFactory = $streamFactory;
×
112
    }
113

114
    /**
115
     * Set timeout.
116
     * @param int $timeout Timeout in seconds.
117
     */
118
    public function setTimeout(int $timeout): void
119
    {
120
        $this->options['timeout'] = $timeout;
×
121
        if (!$this->isConnected()) {
×
122
            return;
×
123
        }
124
        $this->connection->setTimeout($timeout);
×
125
    }
126

127
    /**
128
     * Set fragmentation size.
129
     * @param int $fragment_size Fragment size in bytes.
130
     * @return self.
131
     */
132
    public function setFragmentSize(int $fragment_size): self
133
    {
134
        $this->options['fragment_size'] = $fragment_size;
×
135
        if (!$this->connection) {
×
136
            return $this;
×
137
        }
138
        $this->connection->setOptions(['fragment_size' => $fragment_size]);
×
139
        return $this;
×
140
    }
141

142
    /**
143
     * Get fragmentation size.
144
     * @return int $fragment_size Fragment size in bytes.
145
     */
146
    public function getFragmentSize(): int
147
    {
148
        return $this->options['fragment_size'];
×
149
    }
150

151

152
    /* ---------- Messaging operations ----------------------------------------------------------------------------- */
153

154
    /**
155
     * Send text message.
156
     * @param string $message Content as string.
157
     * @param bool $masked If message should be masked
158
     */
159
    public function text(string $message, ?bool $masked = null): void
160
    {
161
        $this->send(new Text($message), $masked);
×
162
    }
163

164
    /**
165
     * Send binary message.
166
     * @param string $message Content as binary string.
167
     * @param bool $masked If message should be masked
168
     */
169
    public function binary(string $message, ?bool $masked = null): void
170
    {
171
        $this->send(new Binary($message), $masked);
×
172
    }
173

174
    /**
175
     * Send ping.
176
     * @param string $message Optional text as string.
177
     * @param bool $masked If message should be masked
178
     */
179
    public function ping(string $message = '', ?bool $masked = null): void
180
    {
181
        $this->send(new Ping($message), $masked);
×
182
    }
183

184
    /**
185
     * Send unsolicited pong.
186
     * @param string $message Optional text as string.
187
     * @param bool $masked If message should be masked
188
     */
189
    public function pong(string $message = '', ?bool $masked = null): void
190
    {
191
        $this->send(new Pong($message), $masked);
×
192
    }
193

194
    /**
195
     * Tell the socket to close.
196
     * @param integer $status  http://tools.ietf.org/html/rfc6455#section-7.4
197
     * @param string  $message A closing message, max 125 bytes.
198
     * @param bool $masked If message should be masked
199
     */
200
    public function close(int $status = 1000, string $message = 'ttfn', ?bool $masked = null): void
201
    {
202
        if (!$this->isConnected()) {
×
203
            return;
×
204
        }
205
        $this->connection->close($status, $message);
×
206
    }
207

208
    /**
209
     * Send message.
210
     * @param Message $message Message to send.
211
     * @param bool $masked If message should be masked
212
     */
213
    public function send(Message $message, ?bool $masked = null): void
214
    {
215
        if (!$this->isConnected()) {
×
216
            $this->connect();
×
217
        }
218
        $this->connection->pushMessage($message, $masked);
×
219
    }
220

221
    /**
222
     * Receive message.
223
     * Note that this operation will block reading.
224
     * @return Message|null
225
     */
226
    public function receive(): ?Message
227
    {
228
        if (!$this->isConnected()) {
×
229
            $this->connect();
×
230
        }
231
        return $this->connection->pullMessage();
×
232
    }
233

234

235
    /* ---------- Connection management ---------------------------------------------------------------------------- */
236

237
    /**
238
     * If Client has active connection.
239
     * @return bool True if active connection.
240
     */
241
    public function isConnected(): bool
242
    {
243
        return $this->connection && $this->connection->isConnected();
×
244
    }
245

246
    /**
247
     * Connect to server and perform upgrade.
248
     * @throws ConnectionException On failed connection
249
     */
250
    public function connect(): void
251
    {
252
        $this->disconnect();
×
253

254
        $host_uri = (new Uri())
×
255
            ->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp')
×
256
            ->withHost($this->socket_uri->getHost(Uri::IDNA))
×
257
            ->withPort($this->socket_uri->getPort(Uri::REQUIRE_PORT));
×
258

259
        $context = $this->parseContext();
×
260
        $persistent = $this->options['persistent'] === true;
×
261
        $stream = null;
×
262

263
        try {
264
            $client = $this->streamFactory->createSocketClient($host_uri);
×
265
            $client->setPersistent($persistent);
×
266
            $client->setTimeout($this->options['timeout']);
×
267
            $client->setContext($context);
×
268
            $stream = $client->connect();
×
269
        } catch (Throwable $e) {
×
270
            $error = "Could not open socket to \"{$host_uri}\": Server is closed.";
×
271
            $this->logger->error("[client] {$error}", []);
×
272
            throw new ConnectionException($error, ConnectionException::CLIENT_CONNECT_ERR, [], $e);
×
273
        }
274
        $this->connection = new Connection($stream, $this->options);
×
275
        $this->connection->setLogger($this->logger);
×
276
        $this->connection->addMiddleware(new CloseHandler());
×
277
        $this->connection->addMiddleware(new PingResponder());
×
278

279
        if (!$this->isConnected()) {
×
280
            $error = "Invalid stream on \"{$host_uri}\".";
×
281
            $this->logger->error("[client] {$error}");
×
282
            throw new ConnectionException($error, ConnectionException::CLIENT_CONNECT_ERR);
×
283
        }
284

285
        if (!$persistent || $stream->tell() == 0) {
×
286
            $this->handshakeResponse = $this->performHandshake($host_uri);
×
287
        }
288

289
        $this->logger->info("[client] Client connected to {$this->socket_uri}");
×
290
    }
291

292
    /**
293
     * Disconnect from server.
294
     */
295
    public function disconnect(): void
296
    {
297
        if ($this->isConnected()) {
×
298
            $this->connection->disconnect();
×
299
            $this->logger->info('[client] Client disconnected');
×
300
        }
301
    }
302

303

304
    /* ---------- Connection state --------------------------------------------------------------------------------- */
305

306
    /**
307
     * Get name of local socket, or null if not connected.
308
     * @return string|null
309
     */
310
    public function getName(): ?string
311
    {
312
        return $this->isConnected() ? $this->connection->getName() : null;
×
313
    }
314

315
    /**
316
     * Get name of remote socket, or null if not connected.
317
     * @return string|null
318
     */
319
    public function getRemoteName(): ?string
320
    {
321
        return $this->isConnected() ? $this->connection->getRemoteName() : null;
×
322
    }
323

324
    /**
325
     * Get Response for handshake procedure.
326
     * @return Response|null Handshake.
327
     */
328
    public function getHandshakeResponse(): ?Response
329
    {
330
        return $this->connection ? $this->handshakeResponse : null;
×
331
    }
332

333

334
    /* ---------- Internal helper methods -------------------------------------------------------------------------- */
335

336
    /**
337
     * Perform upgrade handshake on new connections.
338
     * @throws ConnectionException On failed handshake
339
     */
340
    protected function performHandshake($host_uri): Response
341
    {
342
        $http_uri = (new Uri())
×
343
            ->withPath($this->socket_uri->getPath(), Uri::ABSOLUTE_PATH)
×
344
            ->withQuery($this->socket_uri->getQuery());
×
345

346
        // Generate the WebSocket key.
347
        $key = $this->generateKey();
×
348

349
        $request = new Request('GET', $http_uri);
×
350

351
        $request = $request
×
352
            ->withHeader('Host', $host_uri->getAuthority())
×
353
            ->withHeader('User-Agent', 'websocket-client-php')
×
354
            ->withHeader('Connection', 'Upgrade')
×
355
            ->withHeader('Upgrade', 'websocket')
×
356
            ->withHeader('Sec-WebSocket-Key', $key)
×
357
            ->withHeader('Sec-WebSocket-Version', '13');
×
358

359
        // Handle basic authentication.
360
        if ($userinfo = $this->socket_uri->getUserInfo()) {
×
361
            $request = $request->withHeader('authorization', 'Basic ' . base64_encode($userinfo));
×
362
        }
363

364
        // Add and override with headers from options.
365
        foreach ($this->options['headers'] as $name => $content) {
×
366
            $request = $request->withHeader($name, $content);
×
367
        }
368

369
        try {
370
            $this->connection->pushHttp($request);
×
371
            $response = $this->connection->pullHttp();
×
372
        } catch (Throwable $e) {
×
373
            $error = 'Client handshake error';
×
374
            $this->logger->error("[client] {$error}");
×
375
            throw new ConnectionException($error, ConnectionException::CLIENT_HANDSHAKE_ERR);
×
376
        }
377

378
        if ($response->getStatusCode() != 101) {
×
379
            $error = "Invalid status code {$response->getStatusCode()}.";
×
380
            $this->logger->error("[client] {$error}");
×
381
            throw new ConnectionException($error, ConnectionException::CLIENT_HANDSHAKE_ERR);
×
382
        }
383

384
        if (empty($response->getHeaderLine('Sec-WebSocket-Accept'))) {
×
385
            $error = sprintf(
×
386
                "Connection to '%s' failed: Server sent invalid upgrade response.",
×
387
                (string)$this->socket_uri
×
388
            );
×
389
            $this->logger->error("[client] {$error}");
×
390
            throw new ConnectionException($error, ConnectionException::CLIENT_HANDSHAKE_ERR);
×
391
        }
392

393
        $response_key = trim($response->getHeaderLine('Sec-WebSocket-Accept'));
×
394
        $expected_key = base64_encode(
×
395
            pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
×
396
        );
×
397

398
        if ($response_key !== $expected_key) {
×
399
            $error = 'Server sent bad upgrade response.';
×
400
            $this->logger->error("[client] {$error}");
×
401
            throw new ConnectionException($error, ConnectionException::CLIENT_HANDSHAKE_ERR);
×
402
        }
403
        return $response;
×
404
    }
405

406
    /**
407
     * Generate a random string for WebSocket key.
408
     * @return string Random string
409
     */
410
    protected function generateKey(): string
411
    {
412
        $key = '';
×
413
        for ($i = 0; $i < 16; $i++) {
×
414
            $key .= chr(rand(33, 126));
×
415
        }
416
        return base64_encode($key);
×
417
    }
418

419
    /**
420
     * Ensure URI insatnce to use in client.
421
     * @param UriInterface|string $uri A ws/wss-URI
422
     * @return Uri
423
     * @throws BadUriException On invalid URI
424
     */
425
    protected function parseUri($uri): UriInterface
426
    {
427
        if ($uri instanceof Uri) {
×
428
            $uri_instance = $uri;
×
429
        } elseif ($uri instanceof UriInterface) {
×
430
            $uri_instance = new Uri("{$uri}");
×
431
        } elseif (is_string($uri)) {
×
432
            try {
433
                $uri_instance = new Uri($uri);
×
434
            } catch (InvalidArgumentException $e) {
×
435
                throw new BadUriException("Invalid URI '{$uri}' provided.");
×
436
            }
437
        } else {
438
            throw new BadUriException("Provided URI must be a UriInterface or string.");
×
439
        }
440
        if (!in_array($uri_instance->getScheme(), ['ws', 'wss'])) {
×
441
            throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'.");
×
442
        }
443
        if (!$uri_instance->getHost()) {
×
444
            throw new BadUriException("Invalid URI host.");
×
445
        }
446
        return $uri_instance;
×
447
    }
448

449
    /**
450
     * Ensure context in correct format.
451
     * @return array
452
     * @throws InvalidArgumentException On invalid context
453
     */
454
    protected function parseContext(): array
455
    {
456
        if (empty($this->options['context'])) {
×
457
            return [];
×
458
        }
459
        if (is_array($this->options['context'])) {
×
460
            return $this->options['context'];
×
461
        }
462
        if (
463
            is_resource($this->options['context'])
×
464
            && get_resource_type($this->options['context']) === 'stream-context'
×
465
        ) {
466
            return stream_context_get_options($this->options['context']);
×
467
        }
468
        $error = "Stream context in \$options['context'] isn't a valid context.";
×
469
        $this->logger->error("[client] {$error}");
×
470
        throw new InvalidArgumentException($error);
×
471
    }
472
}
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