init
This commit is contained in:
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
class BadOpcodeException extends Exception
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
class BadUriException extends Exception
|
||||
{
|
||||
}
|
||||
+490
@@ -0,0 +1,490 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
use ErrorException;
|
||||
use InvalidArgumentException;
|
||||
use Phrity\Net\Uri;
|
||||
use Phrity\Util\ErrorHandler;
|
||||
use Psr\Http\Message\UriInterface;
|
||||
use Psr\Log\{
|
||||
LoggerAwareInterface,
|
||||
LoggerAwareTrait,
|
||||
LoggerInterface,
|
||||
NullLogger
|
||||
};
|
||||
use WebSocket\Message\Factory;
|
||||
|
||||
class Client implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait; // provides setLogger(LoggerInterface $logger)
|
||||
use OpcodeTrait;
|
||||
|
||||
// Default options
|
||||
protected static $default_options = [
|
||||
'context' => null,
|
||||
'filter' => ['text', 'binary'],
|
||||
'fragment_size' => 4096,
|
||||
'headers' => null,
|
||||
'logger' => null,
|
||||
'origin' => null, // @deprecated
|
||||
'persistent' => false,
|
||||
'return_obj' => false,
|
||||
'timeout' => 5,
|
||||
];
|
||||
|
||||
private $socket_uri;
|
||||
private $connection;
|
||||
private $options = [];
|
||||
private $listen = false;
|
||||
private $last_opcode = null;
|
||||
|
||||
|
||||
/* ---------- Magic methods ------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @param UriInterface|string $uri A ws/wss-URI
|
||||
* @param array $options
|
||||
* Associative array containing:
|
||||
* - context: Set the stream context. Default: empty context
|
||||
* - timeout: Set the socket timeout in seconds. Default: 5
|
||||
* - fragment_size: Set framgemnt size. Default: 4096
|
||||
* - headers: Associative array of headers to set/override.
|
||||
*/
|
||||
public function __construct($uri, array $options = [])
|
||||
{
|
||||
$this->socket_uri = $this->parseUri($uri);
|
||||
$this->options = array_merge(self::$default_options, [
|
||||
'logger' => new NullLogger(),
|
||||
], $options);
|
||||
$this->setLogger($this->options['logger']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string representation of instance.
|
||||
* @return string String representation.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf(
|
||||
"%s(%s)",
|
||||
get_class($this),
|
||||
$this->getName() ?: 'closed'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Client option functions -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set timeout.
|
||||
* @param int $timeout Timeout in seconds.
|
||||
*/
|
||||
public function setTimeout(int $timeout): void
|
||||
{
|
||||
$this->options['timeout'] = $timeout;
|
||||
if (!$this->isConnected()) {
|
||||
return;
|
||||
}
|
||||
$this->connection->setTimeout($timeout);
|
||||
$this->connection->setOptions($this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fragmentation size.
|
||||
* @param int $fragment_size Fragment size in bytes.
|
||||
* @return self.
|
||||
*/
|
||||
public function setFragmentSize(int $fragment_size): self
|
||||
{
|
||||
$this->options['fragment_size'] = $fragment_size;
|
||||
$this->connection->setOptions($this->options);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fragmentation size.
|
||||
* @return int $fragment_size Fragment size in bytes.
|
||||
*/
|
||||
public function getFragmentSize(): int
|
||||
{
|
||||
return $this->options['fragment_size'];
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Connection operations ---------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Send text message.
|
||||
* @param string $payload Content as string.
|
||||
*/
|
||||
public function text(string $payload): void
|
||||
{
|
||||
$this->send($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send binary message.
|
||||
* @param string $payload Content as binary string.
|
||||
*/
|
||||
public function binary(string $payload): void
|
||||
{
|
||||
$this->send($payload, 'binary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send ping.
|
||||
* @param string $payload Optional text as string.
|
||||
*/
|
||||
public function ping(string $payload = ''): void
|
||||
{
|
||||
$this->send($payload, 'ping');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send unsolicited pong.
|
||||
* @param string $payload Optional text as string.
|
||||
*/
|
||||
public function pong(string $payload = ''): void
|
||||
{
|
||||
$this->send($payload, 'pong');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message.
|
||||
* @param string $payload Message to send.
|
||||
* @param string $opcode Opcode to use, default: 'text'.
|
||||
* @param bool $masked If message should be masked default: true.
|
||||
*/
|
||||
public function send(string $payload, string $opcode = 'text', bool $masked = true): void
|
||||
{
|
||||
if (!$this->isConnected()) {
|
||||
$this->connect();
|
||||
}
|
||||
|
||||
if (!in_array($opcode, array_keys(self::$opcodes))) {
|
||||
$warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
|
||||
$this->logger->warning($warning);
|
||||
throw new BadOpcodeException($warning);
|
||||
}
|
||||
|
||||
$factory = new Factory();
|
||||
$message = $factory->create($opcode, $payload);
|
||||
$this->connection->pushMessage($message, $masked);
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the socket to close.
|
||||
* @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
|
||||
* @param string $message A closing message, max 125 bytes.
|
||||
*/
|
||||
public function close(int $status = 1000, string $message = 'ttfn'): void
|
||||
{
|
||||
if (!$this->isConnected()) {
|
||||
return;
|
||||
}
|
||||
$this->connection->close($status, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect from server.
|
||||
*/
|
||||
public function disconnect(): void
|
||||
{
|
||||
if ($this->isConnected()) {
|
||||
$this->connection->disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive message.
|
||||
* Note that this operation will block reading.
|
||||
* @return mixed Message, text or null depending on settings.
|
||||
*/
|
||||
public function receive()
|
||||
{
|
||||
$filter = $this->options['filter'];
|
||||
$return_obj = $this->options['return_obj'];
|
||||
|
||||
if (!$this->isConnected()) {
|
||||
$this->connect();
|
||||
}
|
||||
|
||||
while (true) {
|
||||
$message = $this->connection->pullMessage();
|
||||
$opcode = $message->getOpcode();
|
||||
if (in_array($opcode, $filter)) {
|
||||
$this->last_opcode = $opcode;
|
||||
$return = $return_obj ? $message : $message->getContent();
|
||||
break;
|
||||
} elseif ($opcode == 'close') {
|
||||
$this->last_opcode = null;
|
||||
$return = $return_obj ? $message : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Connection functions ----------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get last received opcode.
|
||||
* @return string|null Opcode.
|
||||
*/
|
||||
public function getLastOpcode(): ?string
|
||||
{
|
||||
return $this->last_opcode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get close status on connection.
|
||||
* @return int|null Close status.
|
||||
*/
|
||||
public function getCloseStatus(): ?int
|
||||
{
|
||||
return $this->connection ? $this->connection->getCloseStatus() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If Client has active connection.
|
||||
* @return bool True if active connection.
|
||||
*/
|
||||
public function isConnected(): bool
|
||||
{
|
||||
return $this->connection && $this->connection->isConnected();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of local socket, or null if not connected.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->isConnected() ? $this->connection->getName() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of remote socket, or null if not connected.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getRemoteName(): ?string
|
||||
{
|
||||
return $this->isConnected() ? $this->connection->getRemoteName() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of remote socket, or null if not connected.
|
||||
* @return string|null
|
||||
* @deprecated Will be removed in future version, use getPeer() instead.
|
||||
*/
|
||||
public function getPier(): ?string
|
||||
{
|
||||
trigger_error(
|
||||
'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
return $this->getRemoteName();
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Helper functions --------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Perform WebSocket handshake
|
||||
*/
|
||||
protected function connect(): void
|
||||
{
|
||||
$this->connection = null;
|
||||
|
||||
$host_uri = $this->socket_uri
|
||||
->withScheme($this->socket_uri->getScheme() == 'wss' ? 'ssl' : 'tcp')
|
||||
->withPort($this->socket_uri->getPort() ?? ($this->socket_uri->getScheme() == 'wss' ? 443 : 80))
|
||||
->withPath('')
|
||||
->withQuery('')
|
||||
->withFragment('')
|
||||
->withUserInfo('');
|
||||
|
||||
// Path must be absolute
|
||||
$http_path = $this->socket_uri->getPath();
|
||||
if ($http_path === '' || $http_path[0] !== '/') {
|
||||
$http_path = "/{$http_path}";
|
||||
}
|
||||
|
||||
$http_uri = (new Uri())
|
||||
->withPath($http_path)
|
||||
->withQuery($this->socket_uri->getQuery());
|
||||
|
||||
// Set the stream context options if they're already set in the config
|
||||
if (isset($this->options['context'])) {
|
||||
// Suppress the error since we'll catch it below
|
||||
if (@get_resource_type($this->options['context']) === 'stream-context') {
|
||||
$context = $this->options['context'];
|
||||
} else {
|
||||
$error = "Stream context in \$options['context'] isn't a valid context.";
|
||||
$this->logger->error($error);
|
||||
throw new \InvalidArgumentException($error);
|
||||
}
|
||||
} else {
|
||||
$context = stream_context_create();
|
||||
}
|
||||
|
||||
$persistent = $this->options['persistent'] === true;
|
||||
$flags = STREAM_CLIENT_CONNECT;
|
||||
$flags = $persistent ? $flags | STREAM_CLIENT_PERSISTENT : $flags;
|
||||
$socket = null;
|
||||
|
||||
try {
|
||||
$handler = new ErrorHandler();
|
||||
$socket = $handler->with(function () use ($host_uri, $flags, $context) {
|
||||
$error = $errno = $errstr = null;
|
||||
// Open the socket.
|
||||
return stream_socket_client(
|
||||
$host_uri,
|
||||
$errno,
|
||||
$errstr,
|
||||
$this->options['timeout'],
|
||||
$flags,
|
||||
$context
|
||||
);
|
||||
});
|
||||
if (!$socket) {
|
||||
throw new ErrorException('No socket');
|
||||
}
|
||||
} catch (ErrorException $e) {
|
||||
$error = "Could not open socket to \"{$host_uri->getAuthority()}\": {$e->getMessage()} ({$e->getCode()}).";
|
||||
$this->logger->error($error, ['severity' => $e->getSeverity()]);
|
||||
throw new ConnectionException($error, 0, [], $e);
|
||||
}
|
||||
|
||||
$this->connection = new Connection($socket, $this->options);
|
||||
$this->connection->setLogger($this->logger);
|
||||
if (!$this->isConnected()) {
|
||||
$error = "Invalid stream on \"{$host_uri->getAuthority()}\".";
|
||||
$this->logger->error($error);
|
||||
throw new ConnectionException($error);
|
||||
}
|
||||
|
||||
if (!$persistent || $this->connection->tell() == 0) {
|
||||
// Set timeout on the stream as well.
|
||||
$this->connection->setTimeout($this->options['timeout']);
|
||||
|
||||
// Generate the WebSocket key.
|
||||
$key = self::generateKey();
|
||||
|
||||
// Default headers
|
||||
$headers = [
|
||||
'Host' => $host_uri->getAuthority(),
|
||||
'User-Agent' => 'websocket-client-php',
|
||||
'Connection' => 'Upgrade',
|
||||
'Upgrade' => 'websocket',
|
||||
'Sec-WebSocket-Key' => $key,
|
||||
'Sec-WebSocket-Version' => '13',
|
||||
];
|
||||
|
||||
// Handle basic authentication.
|
||||
if ($userinfo = $this->socket_uri->getUserInfo()) {
|
||||
$headers['authorization'] = 'Basic ' . base64_encode($userinfo);
|
||||
}
|
||||
|
||||
// Deprecated way of adding origin (use headers instead).
|
||||
if (isset($this->options['origin'])) {
|
||||
$headers['origin'] = $this->options['origin'];
|
||||
}
|
||||
|
||||
// Add and override with headers from options.
|
||||
if (isset($this->options['headers'])) {
|
||||
$headers = array_merge($headers, $this->options['headers']);
|
||||
}
|
||||
|
||||
$header = "GET {$http_uri} HTTP/1.1\r\n" . implode(
|
||||
"\r\n",
|
||||
array_map(
|
||||
function ($key, $value) {
|
||||
return "$key: $value";
|
||||
},
|
||||
array_keys($headers),
|
||||
$headers
|
||||
)
|
||||
) . "\r\n\r\n";
|
||||
|
||||
// Send headers.
|
||||
$this->connection->write($header);
|
||||
|
||||
// Get server response header (terminated with double CR+LF).
|
||||
$response = '';
|
||||
try {
|
||||
do {
|
||||
$buffer = $this->connection->gets(1024);
|
||||
$response .= $buffer;
|
||||
} while (substr_count($response, "\r\n\r\n") == 0);
|
||||
} catch (Exception $e) {
|
||||
throw new ConnectionException('Client handshake error', $e->getCode(), $e->getData(), $e);
|
||||
}
|
||||
|
||||
// Validate response.
|
||||
if (!preg_match('#Sec-WebSocket-Accept:\s(.*)$#mUi', $response, $matches)) {
|
||||
$error = sprintf(
|
||||
"Connection to '%s' failed: Server sent invalid upgrade response: %s",
|
||||
(string)$this->socket_uri,
|
||||
(string)$response
|
||||
);
|
||||
$this->logger->error($error);
|
||||
throw new ConnectionException($error);
|
||||
}
|
||||
|
||||
$keyAccept = trim($matches[1]);
|
||||
$expectedResonse = base64_encode(
|
||||
pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'))
|
||||
);
|
||||
|
||||
if ($keyAccept !== $expectedResonse) {
|
||||
$error = 'Server sent bad upgrade response.';
|
||||
$this->logger->error($error);
|
||||
throw new ConnectionException($error);
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->info("Client connected to {$this->socket_uri}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a random string for WebSocket key.
|
||||
* @return string Random string
|
||||
*/
|
||||
protected static function generateKey(): string
|
||||
{
|
||||
$key = '';
|
||||
for ($i = 0; $i < 16; $i++) {
|
||||
$key .= chr(rand(33, 126));
|
||||
}
|
||||
return base64_encode($key);
|
||||
}
|
||||
|
||||
protected function parseUri($uri): UriInterface
|
||||
{
|
||||
if ($uri instanceof UriInterface) {
|
||||
$uri = $uri;
|
||||
} elseif (is_string($uri)) {
|
||||
try {
|
||||
$uri = new Uri($uri);
|
||||
} catch (InvalidArgumentException $e) {
|
||||
throw new BadUriException("Invalid URI '{$uri}' provided.", 0, $e);
|
||||
}
|
||||
} else {
|
||||
throw new BadUriException("Provided URI must be a UriInterface or string.");
|
||||
}
|
||||
if (!in_array($uri->getScheme(), ['ws', 'wss'])) {
|
||||
throw new BadUriException("Invalid URI scheme, must be 'ws' or 'wss'.");
|
||||
}
|
||||
return $uri;
|
||||
}
|
||||
}
|
||||
+518
@@ -0,0 +1,518 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
use Psr\Log\{
|
||||
LoggerAwareInterface,
|
||||
LoggerAwareTrait,
|
||||
LoggerInterface, NullLogger
|
||||
};
|
||||
use WebSocket\Message\{
|
||||
Factory,
|
||||
Message
|
||||
};
|
||||
|
||||
class Connection implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait;
|
||||
use OpcodeTrait;
|
||||
|
||||
private $stream;
|
||||
private $read_buffer;
|
||||
private $msg_factory;
|
||||
private $options = [];
|
||||
|
||||
protected $is_closing = false;
|
||||
protected $close_status = null;
|
||||
|
||||
private $uid;
|
||||
|
||||
/* ---------- Construct & Destruct ----------------------------------------------- */
|
||||
|
||||
public function __construct($stream, array $options = [])
|
||||
{
|
||||
$this->stream = $stream;
|
||||
$this->setOptions($options);
|
||||
$this->setLogger(new NullLogger());
|
||||
$this->msg_factory = new Factory();
|
||||
}
|
||||
|
||||
public function __destruct()
|
||||
{
|
||||
if ($this->getType() === 'stream') {
|
||||
fclose($this->stream);
|
||||
}
|
||||
}
|
||||
|
||||
public function setOptions(array $options = []): void
|
||||
{
|
||||
$this->options = array_merge($this->options, $options);
|
||||
}
|
||||
|
||||
public function getCloseStatus(): ?int
|
||||
{
|
||||
return $this->close_status;
|
||||
}
|
||||
|
||||
/**
|
||||
* Tell the socket to close.
|
||||
*
|
||||
* @param integer $status http://tools.ietf.org/html/rfc6455#section-7.4
|
||||
* @param string $message A closing message, max 125 bytes.
|
||||
*/
|
||||
public function close(int $status = 1000, string $message = 'ttfn'): void
|
||||
{
|
||||
if (!$this->isConnected()) {
|
||||
return;
|
||||
}
|
||||
$status_binstr = sprintf('%016b', $status);
|
||||
$status_str = '';
|
||||
foreach (str_split($status_binstr, 8) as $binstr) {
|
||||
$status_str .= chr(bindec($binstr));
|
||||
}
|
||||
$message = $this->msg_factory->create('close', $status_str . $message);
|
||||
$this->pushMessage($message, true);
|
||||
|
||||
$this->logger->debug("Closing with status: {$status}.");
|
||||
|
||||
$this->is_closing = true;
|
||||
while (true) {
|
||||
$message = $this->pullMessage();
|
||||
if ($message->getOpcode() == 'close') {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Message methods ---------------------------------------------------- */
|
||||
|
||||
// Push a message to stream
|
||||
public function pushMessage(Message $message, bool $masked = true): void
|
||||
{
|
||||
$frames = $message->getFrames($masked, $this->options['fragment_size']);
|
||||
foreach ($frames as $frame) {
|
||||
$this->pushFrame($frame);
|
||||
}
|
||||
$this->logger->info("[connection] Pushed {$message}", [
|
||||
'opcode' => $message->getOpcode(),
|
||||
'content-length' => $message->getLength(),
|
||||
'frames' => count($frames),
|
||||
]);
|
||||
}
|
||||
|
||||
// Pull a message from stream
|
||||
public function pullMessage(): Message
|
||||
{
|
||||
do {
|
||||
$frame = $this->pullFrame();
|
||||
$frame = $this->autoRespond($frame);
|
||||
list ($final, $payload, $opcode, $masked) = $frame;
|
||||
|
||||
if ($opcode == 'close') {
|
||||
$this->close();
|
||||
}
|
||||
|
||||
// Continuation and factual opcode
|
||||
$continuation = $opcode == 'continuation';
|
||||
$payload_opcode = $continuation ? $this->read_buffer['opcode'] : $opcode;
|
||||
|
||||
// First continuation frame, create buffer
|
||||
if (!$final && !$continuation) {
|
||||
$this->read_buffer = ['opcode' => $opcode, 'payload' => $payload, 'frames' => 1];
|
||||
continue; // Continue reading
|
||||
}
|
||||
|
||||
// Subsequent continuation frames, add to buffer
|
||||
if ($continuation) {
|
||||
$this->read_buffer['payload'] .= $payload;
|
||||
$this->read_buffer['frames']++;
|
||||
}
|
||||
} while (!$final);
|
||||
|
||||
// Final, return payload
|
||||
$frames = 1;
|
||||
if ($continuation) {
|
||||
$payload = $this->read_buffer['payload'];
|
||||
$frames = $this->read_buffer['frames'];
|
||||
$this->read_buffer = null;
|
||||
}
|
||||
|
||||
$message = $this->msg_factory->create($payload_opcode, $payload);
|
||||
|
||||
$this->logger->info("[connection] Pulled {$message}", [
|
||||
'opcode' => $payload_opcode,
|
||||
'content-length' => strlen($payload),
|
||||
'frames' => $frames,
|
||||
]);
|
||||
|
||||
return $message;
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Frame I/O methods -------------------------------------------------- */
|
||||
|
||||
// Pull frame from stream
|
||||
private function pullFrame(): array
|
||||
{
|
||||
// Read the fragment "header" first, two bytes.
|
||||
$data = $this->read(2);
|
||||
list ($byte_1, $byte_2) = array_values(unpack('C*', $data));
|
||||
$final = (bool)($byte_1 & 0b10000000); // Final fragment marker.
|
||||
$rsv = $byte_1 & 0b01110000; // Unused bits, ignore
|
||||
|
||||
// Parse opcode
|
||||
$opcode_int = $byte_1 & 0b00001111;
|
||||
$opcode_ints = array_flip(self::$opcodes);
|
||||
if (!array_key_exists($opcode_int, $opcode_ints)) {
|
||||
$warning = "Bad opcode in websocket frame: {$opcode_int}";
|
||||
$this->logger->warning($warning);
|
||||
throw new ConnectionException($warning, ConnectionException::BAD_OPCODE);
|
||||
}
|
||||
$opcode = $opcode_ints[$opcode_int];
|
||||
|
||||
// Masking bit
|
||||
$masked = (bool)($byte_2 & 0b10000000);
|
||||
|
||||
$payload = '';
|
||||
|
||||
// Payload length
|
||||
$payload_length = $byte_2 & 0b01111111;
|
||||
|
||||
if ($payload_length > 125) {
|
||||
if ($payload_length === 126) {
|
||||
$data = $this->read(2); // 126: Payload is a 16-bit unsigned int
|
||||
$payload_length = current(unpack('n', $data));
|
||||
} else {
|
||||
$data = $this->read(8); // 127: Payload is a 64-bit unsigned int
|
||||
$payload_length = current(unpack('J', $data));
|
||||
}
|
||||
}
|
||||
|
||||
// Get masking key.
|
||||
if ($masked) {
|
||||
$masking_key = $this->read(4);
|
||||
}
|
||||
|
||||
// Get the actual payload, if any (might not be for e.g. close frames.
|
||||
if ($payload_length > 0) {
|
||||
$data = $this->read($payload_length);
|
||||
|
||||
if ($masked) {
|
||||
// Unmask payload.
|
||||
for ($i = 0; $i < $payload_length; $i++) {
|
||||
$payload .= ($data[$i] ^ $masking_key[$i % 4]);
|
||||
}
|
||||
} else {
|
||||
$payload = $data;
|
||||
}
|
||||
}
|
||||
|
||||
$this->logger->debug("[connection] Pulled '{opcode}' frame", [
|
||||
'opcode' => $opcode,
|
||||
'final' => $final,
|
||||
'content-length' => strlen($payload),
|
||||
]);
|
||||
return [$final, $payload, $opcode, $masked];
|
||||
}
|
||||
|
||||
// Push frame to stream
|
||||
private function pushFrame(array $frame): void
|
||||
{
|
||||
list ($final, $payload, $opcode, $masked) = $frame;
|
||||
$data = '';
|
||||
$byte_1 = $final ? 0b10000000 : 0b00000000; // Final fragment marker.
|
||||
$byte_1 |= self::$opcodes[$opcode]; // Set opcode.
|
||||
$data .= pack('C', $byte_1);
|
||||
|
||||
$byte_2 = $masked ? 0b10000000 : 0b00000000; // Masking bit marker.
|
||||
|
||||
// 7 bits of payload length...
|
||||
$payload_length = strlen($payload);
|
||||
if ($payload_length > 65535) {
|
||||
$data .= pack('C', $byte_2 | 0b01111111);
|
||||
$data .= pack('J', $payload_length);
|
||||
} elseif ($payload_length > 125) {
|
||||
$data .= pack('C', $byte_2 | 0b01111110);
|
||||
$data .= pack('n', $payload_length);
|
||||
} else {
|
||||
$data .= pack('C', $byte_2 | $payload_length);
|
||||
}
|
||||
|
||||
// Handle masking
|
||||
if ($masked) {
|
||||
// generate a random mask:
|
||||
$mask = '';
|
||||
for ($i = 0; $i < 4; $i++) {
|
||||
$mask .= chr(rand(0, 255));
|
||||
}
|
||||
$data .= $mask;
|
||||
|
||||
// Append payload to frame:
|
||||
for ($i = 0; $i < $payload_length; $i++) {
|
||||
$data .= $payload[$i] ^ $mask[$i % 4];
|
||||
}
|
||||
} else {
|
||||
$data .= $payload;
|
||||
}
|
||||
|
||||
$this->write($data);
|
||||
|
||||
$this->logger->debug("[connection] Pushed '{$opcode}' frame", [
|
||||
'opcode' => $opcode,
|
||||
'final' => $final,
|
||||
'content-length' => strlen($payload),
|
||||
]);
|
||||
}
|
||||
|
||||
// Trigger auto response for frame
|
||||
private function autoRespond(array $frame)
|
||||
{
|
||||
list ($final, $payload, $opcode, $masked) = $frame;
|
||||
$payload_length = strlen($payload);
|
||||
|
||||
switch ($opcode) {
|
||||
case 'ping':
|
||||
// If we received a ping, respond with a pong
|
||||
$this->logger->debug("[connection] Received 'ping', sending 'pong'.");
|
||||
$message = $this->msg_factory->create('pong', $payload);
|
||||
$this->pushMessage($message, $masked);
|
||||
return [$final, $payload, $opcode, $masked];
|
||||
case 'close':
|
||||
// If we received close, possibly acknowledge and close connection
|
||||
$status_bin = '';
|
||||
$status = '';
|
||||
if ($payload_length > 0) {
|
||||
$status_bin = $payload[0] . $payload[1];
|
||||
$status = current(unpack('n', $payload));
|
||||
$this->close_status = $status;
|
||||
}
|
||||
// Get additional close message
|
||||
if ($payload_length >= 2) {
|
||||
$payload = substr($payload, 2);
|
||||
}
|
||||
|
||||
$this->logger->debug("[connection] Received 'close', status: {$status}.");
|
||||
if (!$this->is_closing) {
|
||||
$ack = "{$status_bin}Close acknowledged: {$status}";
|
||||
$message = $this->msg_factory->create('close', $ack);
|
||||
$this->pushMessage($message, $masked);
|
||||
} else {
|
||||
$this->is_closing = false; // A close response, all done.
|
||||
}
|
||||
$this->disconnect();
|
||||
return [$final, $payload, $opcode, $masked];
|
||||
default:
|
||||
return [$final, $payload, $opcode, $masked];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Stream I/O methods ------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Close connection stream.
|
||||
* @return bool
|
||||
*/
|
||||
public function disconnect(): bool
|
||||
{
|
||||
$this->logger->debug('Closing connection');
|
||||
return fclose($this->stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* If connected to stream.
|
||||
* @return bool
|
||||
*/
|
||||
public function isConnected(): bool
|
||||
{
|
||||
return in_array($this->getType(), ['stream', 'persistent stream']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return type of connection.
|
||||
* @return string|null Type of connection or null if invalid type.
|
||||
*/
|
||||
public function getType(): ?string
|
||||
{
|
||||
return get_resource_type($this->stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of local socket, or null if not connected.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getName(): ?string
|
||||
{
|
||||
return stream_socket_get_name($this->stream, false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of remote socket, or null if not connected.
|
||||
* @return string|null
|
||||
*/
|
||||
public function getRemoteName(): ?string
|
||||
{
|
||||
return stream_socket_get_name($this->stream, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get meta data for connection.
|
||||
* @return array
|
||||
*/
|
||||
public function getMeta(): array
|
||||
{
|
||||
return stream_get_meta_data($this->stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns current position of stream pointer.
|
||||
* @return int
|
||||
* @throws ConnectionException
|
||||
*/
|
||||
public function tell(): int
|
||||
{
|
||||
$tell = ftell($this->stream);
|
||||
if ($tell === false) {
|
||||
$this->throwException('Could not resolve stream pointer position');
|
||||
}
|
||||
return $tell;
|
||||
}
|
||||
|
||||
/**
|
||||
* If stream pointer is at end of file.
|
||||
* @return bool
|
||||
*/
|
||||
public function eof(): int
|
||||
{
|
||||
return feof($this->stream);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Stream option methods ---------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Set time out on connection.
|
||||
* @param int $seconds Timeout part in seconds
|
||||
* @param int $microseconds Timeout part in microseconds
|
||||
* @return bool
|
||||
*/
|
||||
public function setTimeout(int $seconds, int $microseconds = 0): bool
|
||||
{
|
||||
$this->logger->debug("Setting timeout {$seconds}:{$microseconds} seconds");
|
||||
return stream_set_timeout($this->stream, $seconds, $microseconds);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Stream read/write methods ------------------------------------------ */
|
||||
|
||||
/**
|
||||
* Read line from stream.
|
||||
* @param int $length Maximum number of bytes to read
|
||||
* @param string $ending Line delimiter
|
||||
* @return string Read data
|
||||
*/
|
||||
public function getLine(int $length, string $ending): string
|
||||
{
|
||||
$line = stream_get_line($this->stream, $length, $ending);
|
||||
if ($line === false) {
|
||||
$this->throwException('Could not read from stream');
|
||||
}
|
||||
$read = strlen($line);
|
||||
$this->logger->debug("Read {$read} bytes of line.");
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read line from stream.
|
||||
* @param int $length Maximum number of bytes to read
|
||||
* @return string Read data
|
||||
*/
|
||||
public function gets(int $length): string
|
||||
{
|
||||
$line = fgets($this->stream, $length);
|
||||
if ($line === false) {
|
||||
$this->throwException('Could not read from stream');
|
||||
}
|
||||
$read = strlen($line);
|
||||
$this->logger->debug("Read {$read} bytes of line.");
|
||||
return $line;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read characters from stream.
|
||||
* @param int $length Maximum number of bytes to read
|
||||
* @return string Read data
|
||||
*/
|
||||
public function read(string $length): string
|
||||
{
|
||||
$data = '';
|
||||
while (strlen($data) < $length) {
|
||||
$buffer = fread($this->stream, $length - strlen($data));
|
||||
if (!$buffer) {
|
||||
$meta = stream_get_meta_data($this->stream);
|
||||
if (!empty($meta['timed_out'])) {
|
||||
$message = 'Client read timeout';
|
||||
$this->logger->error($message, $meta);
|
||||
throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta);
|
||||
}
|
||||
}
|
||||
if ($buffer === false) {
|
||||
$read = strlen($data);
|
||||
$this->throwException("Broken frame, read {$read} of stated {$length} bytes.");
|
||||
}
|
||||
if ($buffer === '') {
|
||||
$this->throwException("Empty read; connection dead?");
|
||||
}
|
||||
$data .= $buffer;
|
||||
$read = strlen($data);
|
||||
$this->logger->debug("Read {$read} of {$length} bytes.");
|
||||
}
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write characters to stream.
|
||||
* @param string $data Data to read
|
||||
*/
|
||||
public function write(string $data): void
|
||||
{
|
||||
$length = strlen($data);
|
||||
$written = fwrite($this->stream, $data);
|
||||
if ($written === false) {
|
||||
$this->throwException("Failed to write {$length} bytes.");
|
||||
}
|
||||
if ($written < strlen($data)) {
|
||||
$this->throwException("Could only write {$written} out of {$length} bytes.");
|
||||
}
|
||||
$this->logger->debug("Wrote {$written} of {$length} bytes.");
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Internal helper methods -------------------------------------------- */
|
||||
|
||||
private function throwException(string $message, int $code = 0): void
|
||||
{
|
||||
$meta = ['closed' => true];
|
||||
if ($this->isConnected()) {
|
||||
$meta = $this->getMeta();
|
||||
$this->disconnect();
|
||||
if (!empty($meta['timed_out'])) {
|
||||
$this->logger->error($message, $meta);
|
||||
throw new TimeoutException($message, ConnectionException::TIMED_OUT, $meta);
|
||||
}
|
||||
if (!empty($meta['eof'])) {
|
||||
$code = ConnectionException::EOF;
|
||||
}
|
||||
}
|
||||
$this->logger->error($message, $meta);
|
||||
throw new ConnectionException($message, $code, $meta);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
use Throwable;
|
||||
|
||||
class ConnectionException extends Exception
|
||||
{
|
||||
// Native codes in interval 0-106
|
||||
public const TIMED_OUT = 1024;
|
||||
public const EOF = 1025;
|
||||
public const BAD_OPCODE = 1026;
|
||||
|
||||
private $data;
|
||||
|
||||
public function __construct(string $message, int $code = 0, array $data = [], Throwable $prev = null)
|
||||
{
|
||||
parent::__construct($message, $code, $prev);
|
||||
$this->data = $data;
|
||||
}
|
||||
|
||||
public function getData(): array
|
||||
{
|
||||
return $this->data;
|
||||
}
|
||||
}
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
class Exception extends \Exception
|
||||
{
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
class Binary extends Message
|
||||
{
|
||||
protected $opcode = 'binary';
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
class Close extends Message
|
||||
{
|
||||
protected $opcode = 'close';
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
use WebSocket\BadOpcodeException;
|
||||
|
||||
class Factory
|
||||
{
|
||||
public function create(string $opcode, string $payload = ''): Message
|
||||
{
|
||||
switch ($opcode) {
|
||||
case 'text':
|
||||
return new Text($payload);
|
||||
case 'binary':
|
||||
return new Binary($payload);
|
||||
case 'ping':
|
||||
return new Ping($payload);
|
||||
case 'pong':
|
||||
return new Pong($payload);
|
||||
case 'close':
|
||||
return new Close($payload);
|
||||
}
|
||||
throw new BadOpcodeException("Invalid opcode '{$opcode}' provided");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
use DateTime;
|
||||
|
||||
abstract class Message
|
||||
{
|
||||
protected $opcode;
|
||||
protected $payload;
|
||||
protected $timestamp;
|
||||
|
||||
public function __construct(string $payload = '')
|
||||
{
|
||||
$this->payload = $payload;
|
||||
$this->timestamp = new DateTime();
|
||||
}
|
||||
|
||||
public function getOpcode(): string
|
||||
{
|
||||
return $this->opcode;
|
||||
}
|
||||
|
||||
public function getLength(): int
|
||||
{
|
||||
return strlen($this->payload);
|
||||
}
|
||||
|
||||
public function getTimestamp(): DateTime
|
||||
{
|
||||
return $this->timestamp;
|
||||
}
|
||||
|
||||
public function getContent(): string
|
||||
{
|
||||
return $this->payload;
|
||||
}
|
||||
|
||||
public function setContent(string $payload = ''): void
|
||||
{
|
||||
$this->payload = $payload;
|
||||
}
|
||||
|
||||
public function hasContent(): bool
|
||||
{
|
||||
return $this->payload != '';
|
||||
}
|
||||
|
||||
public function __toString(): string
|
||||
{
|
||||
return get_class($this);
|
||||
}
|
||||
|
||||
// Split messages into frames
|
||||
public function getFrames(bool $masked = true, int $framesize = 4096): array
|
||||
{
|
||||
|
||||
$frames = [];
|
||||
$split = str_split($this->getContent(), $framesize) ?: [''];
|
||||
foreach ($split as $payload) {
|
||||
$frames[] = [false, $payload, 'continuation', $masked];
|
||||
}
|
||||
$frames[0][2] = $this->opcode;
|
||||
$frames[array_key_last($frames)][0] = true;
|
||||
return $frames;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
class Ping extends Message
|
||||
{
|
||||
protected $opcode = 'ping';
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
class Pong extends Message
|
||||
{
|
||||
protected $opcode = 'pong';
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket\Message;
|
||||
|
||||
class Text extends Message
|
||||
{
|
||||
protected $opcode = 'text';
|
||||
}
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
trait OpcodeTrait
|
||||
{
|
||||
private static $opcodes = [
|
||||
'continuation' => 0,
|
||||
'text' => 1,
|
||||
'binary' => 2,
|
||||
'close' => 8,
|
||||
'ping' => 9,
|
||||
'pong' => 10,
|
||||
];
|
||||
}
|
||||
+470
@@ -0,0 +1,470 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
use Closure;
|
||||
use ErrorException;
|
||||
use Phrity\Util\ErrorHandler;
|
||||
use Psr\Log\{
|
||||
LoggerAwareInterface,
|
||||
LoggerAwareTrait,
|
||||
LoggerInterface,
|
||||
NullLogger
|
||||
};
|
||||
use Throwable;
|
||||
use WebSocket\Message\Factory;
|
||||
|
||||
class Server implements LoggerAwareInterface
|
||||
{
|
||||
use LoggerAwareTrait; // Provides setLogger(LoggerInterface $logger)
|
||||
use OpcodeTrait;
|
||||
|
||||
// Default options
|
||||
protected static $default_options = [
|
||||
'filter' => ['text', 'binary'],
|
||||
'fragment_size' => 4096,
|
||||
'logger' => null,
|
||||
'port' => 8000,
|
||||
'return_obj' => false,
|
||||
'timeout' => null,
|
||||
];
|
||||
|
||||
protected $port;
|
||||
protected $listening;
|
||||
protected $request;
|
||||
protected $request_path;
|
||||
private $connections = [];
|
||||
private $options = [];
|
||||
private $listen = false;
|
||||
private $last_opcode;
|
||||
|
||||
|
||||
/* ---------- Magic methods ------------------------------------------------------ */
|
||||
|
||||
/**
|
||||
* @param array $options
|
||||
* Associative array containing:
|
||||
* - filter: Array of opcodes to handle. Default: ['text', 'binary'].
|
||||
* - fragment_size: Set framgemnt size. Default: 4096
|
||||
* - logger: PSR-3 compatible logger. Default NullLogger.
|
||||
* - port: Chose port for listening. Default 8000.
|
||||
* - return_obj: If receive() function return Message instance. Default false.
|
||||
* - timeout: Set the socket timeout in seconds.
|
||||
*/
|
||||
public function __construct(array $options = [])
|
||||
{
|
||||
$this->options = array_merge(self::$default_options, [
|
||||
'logger' => new NullLogger(),
|
||||
], $options);
|
||||
$this->port = $this->options['port'];
|
||||
$this->setLogger($this->options['logger']);
|
||||
|
||||
$error = $errno = $errstr = null;
|
||||
set_error_handler(function (int $severity, string $message, string $file, int $line) use (&$error) {
|
||||
$this->logger->warning($message, ['severity' => $severity]);
|
||||
$error = $message;
|
||||
}, E_ALL);
|
||||
|
||||
do {
|
||||
$this->listening = stream_socket_server("tcp://0.0.0.0:$this->port", $errno, $errstr);
|
||||
} while ($this->listening === false && $this->port++ < 10000);
|
||||
|
||||
restore_error_handler();
|
||||
|
||||
if (!$this->listening) {
|
||||
$error = "Could not open listening socket: {$errstr} ({$errno}) {$error}";
|
||||
$this->logger->error($error);
|
||||
throw new ConnectionException($error, (int)$errno);
|
||||
}
|
||||
|
||||
$this->logger->info("Server listening to port {$this->port}");
|
||||
}
|
||||
|
||||
/**
|
||||
* Get string representation of instance.
|
||||
* @return string String representation.
|
||||
*/
|
||||
public function __toString(): string
|
||||
{
|
||||
return sprintf(
|
||||
"%s(%s)",
|
||||
get_class($this),
|
||||
$this->getName() ?: 'closed'
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Server operations -------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Accept a single incoming request.
|
||||
* Note that this operation will block accepting additional requests.
|
||||
* @return bool True if listening.
|
||||
*/
|
||||
public function accept(): bool
|
||||
{
|
||||
$this->disconnect();
|
||||
return (bool)$this->listening;
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Server option functions -------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get current port.
|
||||
* @return int port.
|
||||
*/
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set timeout.
|
||||
* @param int $timeout Timeout in seconds.
|
||||
*/
|
||||
public function setTimeout(int $timeout): void
|
||||
{
|
||||
$this->options['timeout'] = $timeout;
|
||||
if (!$this->isConnected()) {
|
||||
return;
|
||||
}
|
||||
foreach ($this->connections as $connection) {
|
||||
$connection->setTimeout($timeout);
|
||||
$connection->setOptions($this->options);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set fragmentation size.
|
||||
* @param int $fragment_size Fragment size in bytes.
|
||||
* @return self.
|
||||
*/
|
||||
public function setFragmentSize(int $fragment_size): self
|
||||
{
|
||||
$this->options['fragment_size'] = $fragment_size;
|
||||
foreach ($this->connections as $connection) {
|
||||
$connection->setOptions($this->options);
|
||||
}
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get fragmentation size.
|
||||
* @return int $fragment_size Fragment size in bytes.
|
||||
*/
|
||||
public function getFragmentSize(): int
|
||||
{
|
||||
return $this->options['fragment_size'];
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Connection broadcast operations ------------------------------------ */
|
||||
|
||||
/**
|
||||
* Broadcast text message to all conenctions.
|
||||
* @param string $payload Content as string.
|
||||
*/
|
||||
public function text(string $payload): void
|
||||
{
|
||||
$this->send($payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast binary message to all conenctions.
|
||||
* @param string $payload Content as binary string.
|
||||
*/
|
||||
public function binary(string $payload): void
|
||||
{
|
||||
$this->send($payload, 'binary');
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast ping message to all conenctions.
|
||||
* @param string $payload Optional text as string.
|
||||
*/
|
||||
public function ping(string $payload = ''): void
|
||||
{
|
||||
$this->send($payload, 'ping');
|
||||
}
|
||||
|
||||
/**
|
||||
* Broadcast pong message to all conenctions.
|
||||
* @param string $payload Optional text as string.
|
||||
*/
|
||||
public function pong(string $payload = ''): void
|
||||
{
|
||||
$this->send($payload, 'pong');
|
||||
}
|
||||
|
||||
/**
|
||||
* Send message on all connections.
|
||||
* @param string $payload Message to send.
|
||||
* @param string $opcode Opcode to use, default: 'text'.
|
||||
* @param bool $masked If message should be masked default: true.
|
||||
*/
|
||||
public function send(string $payload, string $opcode = 'text', bool $masked = true): void
|
||||
{
|
||||
if (!$this->isConnected()) {
|
||||
$this->connect();
|
||||
}
|
||||
if (!in_array($opcode, array_keys(self::$opcodes))) {
|
||||
$warning = "Bad opcode '{$opcode}'. Try 'text' or 'binary'.";
|
||||
$this->logger->warning($warning);
|
||||
throw new BadOpcodeException($warning);
|
||||
}
|
||||
|
||||
$factory = new Factory();
|
||||
$message = $factory->create($opcode, $payload);
|
||||
|
||||
foreach ($this->connections as $connection) {
|
||||
$connection->pushMessage($message, $masked);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Close all connections.
|
||||
* @param int $status Close status, default: 1000.
|
||||
* @param string $message Close message, default: 'ttfn'.
|
||||
*/
|
||||
public function close(int $status = 1000, string $message = 'ttfn'): void
|
||||
{
|
||||
foreach ($this->connections as $connection) {
|
||||
if ($connection->isConnected()) {
|
||||
$connection->close($status, $message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Disconnect all connections.
|
||||
*/
|
||||
public function disconnect(): void
|
||||
{
|
||||
foreach ($this->connections as $connection) {
|
||||
if ($connection->isConnected()) {
|
||||
$connection->disconnect();
|
||||
}
|
||||
}
|
||||
$this->connections = [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Receive message from single connection.
|
||||
* Note that this operation will block reading and only read from first available connection.
|
||||
* @return mixed Message, text or null depending on settings.
|
||||
*/
|
||||
public function receive()
|
||||
{
|
||||
$filter = $this->options['filter'];
|
||||
$return_obj = $this->options['return_obj'];
|
||||
|
||||
if (!$this->isConnected()) {
|
||||
$this->connect();
|
||||
}
|
||||
$connection = current($this->connections);
|
||||
|
||||
while (true) {
|
||||
$message = $connection->pullMessage();
|
||||
$opcode = $message->getOpcode();
|
||||
if (in_array($opcode, $filter)) {
|
||||
$this->last_opcode = $opcode;
|
||||
$return = $return_obj ? $message : $message->getContent();
|
||||
break;
|
||||
} elseif ($opcode == 'close') {
|
||||
$this->last_opcode = null;
|
||||
$return = $return_obj ? $message : null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
return $return;
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Connection functions ----------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Get requested path from last connection.
|
||||
* @return string Path.
|
||||
*/
|
||||
public function getPath(): string
|
||||
{
|
||||
return $this->request_path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get request from last connection.
|
||||
* @return array Request.
|
||||
*/
|
||||
public function getRequest(): array
|
||||
{
|
||||
return $this->request;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get headers from last connection.
|
||||
* @return string|null Headers.
|
||||
*/
|
||||
public function getHeader($header): ?string
|
||||
{
|
||||
foreach ($this->request as $row) {
|
||||
if (stripos($row, $header) !== false) {
|
||||
list($headername, $headervalue) = explode(":", $row);
|
||||
return trim($headervalue);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get last received opcode.
|
||||
* @return string|null Opcode.
|
||||
*/
|
||||
public function getLastOpcode(): ?string
|
||||
{
|
||||
return $this->last_opcode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get close status from single connection.
|
||||
* @return int|null Close status.
|
||||
*/
|
||||
public function getCloseStatus(): ?int
|
||||
{
|
||||
return $this->connections ? current($this->connections)->getCloseStatus() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* If Server has active connections.
|
||||
* @return bool True if active connection.
|
||||
*/
|
||||
public function isConnected(): bool
|
||||
{
|
||||
foreach ($this->connections as $connection) {
|
||||
if ($connection->isConnected()) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of local socket from single connection.
|
||||
* @return string|null Name of local socket.
|
||||
*/
|
||||
public function getName(): ?string
|
||||
{
|
||||
return $this->isConnected() ? current($this->connections)->getName() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get name of remote socket from single connection.
|
||||
* @return string|null Name of remote socket.
|
||||
*/
|
||||
public function getRemoteName(): ?string
|
||||
{
|
||||
return $this->isConnected() ? current($this->connections)->getRemoteName() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated Will be removed in future version.
|
||||
*/
|
||||
public function getPier(): ?string
|
||||
{
|
||||
trigger_error(
|
||||
'getPier() is deprecated and will be removed in future version. Use getRemoteName() instead.',
|
||||
E_USER_DEPRECATED
|
||||
);
|
||||
return $this->getRemoteName();
|
||||
}
|
||||
|
||||
|
||||
/* ---------- Helper functions --------------------------------------------------- */
|
||||
|
||||
// Connect when read/write operation is performed.
|
||||
private function connect(): void
|
||||
{
|
||||
try {
|
||||
$handler = new ErrorHandler();
|
||||
$socket = $handler->with(function () {
|
||||
if (isset($this->options['timeout'])) {
|
||||
$socket = stream_socket_accept($this->listening, $this->options['timeout']);
|
||||
} else {
|
||||
$socket = stream_socket_accept($this->listening);
|
||||
}
|
||||
if (!$socket) {
|
||||
throw new ErrorException('No socket');
|
||||
}
|
||||
return $socket;
|
||||
});
|
||||
} catch (ErrorException $e) {
|
||||
$error = "Server failed to connect. {$e->getMessage()}";
|
||||
$this->logger->error($error, ['severity' => $e->getSeverity()]);
|
||||
throw new ConnectionException($error, 0, [], $e);
|
||||
}
|
||||
|
||||
$connection = new Connection($socket, $this->options);
|
||||
$connection->setLogger($this->logger);
|
||||
|
||||
if (isset($this->options['timeout'])) {
|
||||
$connection->setTimeout($this->options['timeout']);
|
||||
}
|
||||
|
||||
$this->logger->info("Client has connected to port {port}", [
|
||||
'port' => $this->port,
|
||||
'peer' => $connection->getRemoteName(),
|
||||
]);
|
||||
$this->performHandshake($connection);
|
||||
$this->connections = ['*' => $connection];
|
||||
}
|
||||
|
||||
// Perform upgrade handshake on new connections.
|
||||
private function performHandshake(Connection $connection): void
|
||||
{
|
||||
$request = '';
|
||||
do {
|
||||
$buffer = $connection->getLine(1024, "\r\n");
|
||||
$request .= $buffer . "\n";
|
||||
$metadata = $connection->getMeta();
|
||||
} while (!$connection->eof() && $metadata['unread_bytes'] > 0);
|
||||
|
||||
if (!preg_match('/GET (.*) HTTP\//mUi', $request, $matches)) {
|
||||
$error = "No GET in request: {$request}";
|
||||
$this->logger->error($error);
|
||||
throw new ConnectionException($error);
|
||||
}
|
||||
$get_uri = trim($matches[1]);
|
||||
$uri_parts = parse_url($get_uri);
|
||||
|
||||
$this->request = explode("\n", $request);
|
||||
$this->request_path = $uri_parts['path'];
|
||||
/// @todo Get query and fragment as well.
|
||||
|
||||
if (!preg_match('#Sec-WebSocket-Key:\s(.*)$#mUi', $request, $matches)) {
|
||||
$error = "Client had no Key in upgrade request: {$request}";
|
||||
$this->logger->error($error);
|
||||
throw new ConnectionException($error);
|
||||
}
|
||||
|
||||
$key = trim($matches[1]);
|
||||
|
||||
/// @todo Validate key length and base 64...
|
||||
$response_key = base64_encode(pack('H*', sha1($key . '258EAFA5-E914-47DA-95CA-C5AB0DC85B11')));
|
||||
|
||||
$header = "HTTP/1.1 101 Switching Protocols\r\n"
|
||||
. "Upgrade: websocket\r\n"
|
||||
. "Connection: Upgrade\r\n"
|
||||
. "Sec-WebSocket-Accept: $response_key\r\n"
|
||||
. "\r\n";
|
||||
|
||||
$connection->write($header);
|
||||
$this->logger->debug("Handshake on {$get_uri}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
/**
|
||||
* Copyright (C) 2014-2022 Textalk/Abicart and contributors.
|
||||
*
|
||||
* This file is part of Websocket PHP and is free software under the ISC License.
|
||||
* License text: https://raw.githubusercontent.com/Textalk/websocket-php/master/COPYING
|
||||
*/
|
||||
|
||||
namespace WebSocket;
|
||||
|
||||
class TimeoutException extends ConnectionException
|
||||
{
|
||||
}
|
||||
Reference in New Issue
Block a user