Moved frame logic to WebSocketCodec
This commit is contained in:
parent
065d96f90a
commit
1caeb3c29f
1
.gitignore
vendored
1
.gitignore
vendored
@ -1,2 +1,3 @@
|
|||||||
/vendor/
|
/vendor/
|
||||||
/composer.lock
|
/composer.lock
|
||||||
|
/.phpunit.*
|
||||||
|
@ -16,5 +16,8 @@
|
|||||||
],
|
],
|
||||||
"require": {
|
"require": {
|
||||||
"react/http": "^1.9.0"
|
"react/http": "^1.9.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
23
phpunit.xml
Normal file
23
phpunit.xml
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/11.0/phpunit.xsd"
|
||||||
|
bootstrap="vendor/autoload.php"
|
||||||
|
cacheDirectory=".phpunit.cache"
|
||||||
|
executionOrder="depends,defects"
|
||||||
|
requireCoverageMetadata="true"
|
||||||
|
beStrictAboutCoverageMetadata="true"
|
||||||
|
beStrictAboutOutputDuringTests="true"
|
||||||
|
failOnRisky="true"
|
||||||
|
failOnWarning="true">
|
||||||
|
<testsuites>
|
||||||
|
<testsuite name="default">
|
||||||
|
<directory>tests</directory>
|
||||||
|
</testsuite>
|
||||||
|
</testsuites>
|
||||||
|
|
||||||
|
<source restrictDeprecations="true" restrictNotices="true" restrictWarnings="true">
|
||||||
|
<include>
|
||||||
|
<directory>src</directory>
|
||||||
|
</include>
|
||||||
|
</source>
|
||||||
|
</phpunit>
|
189
src/WebSocketCodec.php
Normal file
189
src/WebSocketCodec.php
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoccyLabs\React\WebSocket;
|
||||||
|
|
||||||
|
use Evenement\EventEmitterTrait;
|
||||||
|
use NoccyLabs\React\WebSocket\Group\ConnectionGroup;
|
||||||
|
use NoccyLabs\React\WebSocket\Group\GroupManager;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use React\Http\Message\Response;
|
||||||
|
use React\Socket\ConnectionInterface;
|
||||||
|
use React\Stream\CompositeStream;
|
||||||
|
use React\Stream\DuplexStreamInterface;
|
||||||
|
use React\Stream\ReadableStreamInterface;
|
||||||
|
use React\Stream\ThroughStream;
|
||||||
|
use React\Stream\WritableStreamInterface;
|
||||||
|
|
||||||
|
class WebSocketCodec
|
||||||
|
{
|
||||||
|
|
||||||
|
const OP_CONTINUATION = 0x0;
|
||||||
|
const OP_FRAME_TEXT = 0x1;
|
||||||
|
const OP_FRAME_BINARY = 0x2;
|
||||||
|
const OP_CLOSE = 0x8;
|
||||||
|
const OP_PING = 0x9;
|
||||||
|
const OP_PONG = 0xA;
|
||||||
|
|
||||||
|
public function encode(array $frame): string
|
||||||
|
{
|
||||||
|
// Encoded frame
|
||||||
|
$encoded = null;
|
||||||
|
|
||||||
|
// Unpack frame with defaults
|
||||||
|
$frame = [
|
||||||
|
...[
|
||||||
|
'final' => true,
|
||||||
|
'opcode' => null,
|
||||||
|
'masked' => false,
|
||||||
|
'mask' => null,
|
||||||
|
'rsv1' => false,
|
||||||
|
'rsv2' => false,
|
||||||
|
'rsv3' => false,
|
||||||
|
'payload' => null
|
||||||
|
],
|
||||||
|
...$frame
|
||||||
|
];
|
||||||
|
|
||||||
|
$len = strlen($frame['payload']);
|
||||||
|
if ($len > 65535) {
|
||||||
|
$size0 = 127;
|
||||||
|
$size1 = ($len >> 24) & 0xFF;
|
||||||
|
$size2 = ($len >> 16) & 0xFF;
|
||||||
|
$size3 = ($len >> 8) & 0xFF;
|
||||||
|
$size4 = $len & 0xFF;
|
||||||
|
} elseif ($len > 126) {
|
||||||
|
$size0 = 126;
|
||||||
|
$size1 = ($len >> 8) & 0xFF;
|
||||||
|
$size2 = $len & 0xFF;
|
||||||
|
$size3 = null;
|
||||||
|
} else {
|
||||||
|
$size0 = $len;
|
||||||
|
$size1 = null;
|
||||||
|
$size3 = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encoded .= chr(($frame['final']?0x80:0x00)
|
||||||
|
| ($frame['rsv1']?0x40:0x00)
|
||||||
|
| ($frame['rsv2']?0x20:0x00)
|
||||||
|
| ($frame['rsv3']?0x10:0x00)
|
||||||
|
| ($frame['opcode'] & 0xF));
|
||||||
|
$encoded .= chr(($frame['masked']?0x80:0x00)
|
||||||
|
| ($size0 & 0x7F));
|
||||||
|
if ($size1 !== null) {
|
||||||
|
$encoded .= chr($size1) . chr($size2);
|
||||||
|
}
|
||||||
|
if ($size3 !== null) {
|
||||||
|
$encoded .= chr($size3) . chr($size4);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($frame['masked'] === true) {
|
||||||
|
if ($frame['mask'] === null || strlen($frame['mask']) !== 4) {
|
||||||
|
$frame['mask'] = chr(mt_rand(0,255)).chr(mt_rand(0,255)).chr(mt_rand(0,255)).chr(mt_rand(0,255));
|
||||||
|
}
|
||||||
|
$encoded .= $frame['mask'];
|
||||||
|
$encoded .= $this->mask($frame['payload'], $frame['mask']);
|
||||||
|
} else {
|
||||||
|
$encoded .= $frame['payload'];
|
||||||
|
}
|
||||||
|
|
||||||
|
//$this->hexdump($encoded);
|
||||||
|
|
||||||
|
return $encoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Decode a websocket frame and return an array with the keys:
|
||||||
|
* opcode (int) - the opcode
|
||||||
|
* fin (bool) - final frame
|
||||||
|
* rsv1-3 (bool) - reserved bits
|
||||||
|
* masked (bool) - if the frame was masked
|
||||||
|
* length (int) - length of payload
|
||||||
|
* payload (string) - the payload
|
||||||
|
*/
|
||||||
|
public function decode($frame): array
|
||||||
|
{
|
||||||
|
// Decoded frame
|
||||||
|
$decoded = [];
|
||||||
|
|
||||||
|
// Keep track of the number of bytes in the header
|
||||||
|
$header = 2;
|
||||||
|
|
||||||
|
// Peek at the first byte, holding flags and opcode
|
||||||
|
$byte0 = ord($frame[0]);
|
||||||
|
$decoded['final'] = !!($byte0 & 0x80);
|
||||||
|
$decoded['opcode'] = $byte0 & 0x0F;
|
||||||
|
// Peek at the second byte, holding mask bit and len
|
||||||
|
$byte1 = ord($frame[1]);
|
||||||
|
$decoded['masked'] = $masked = !!($byte1 & 0x80);
|
||||||
|
$decoded['rsv1'] = !!($byte1 & 0x40);
|
||||||
|
$decoded['rsv2'] = !!($byte1 & 0x20);
|
||||||
|
$decoded['rsv3'] = !!($byte1 & 0x10);
|
||||||
|
$len = $byte1 & 0x7F;
|
||||||
|
|
||||||
|
// Read extended length if present
|
||||||
|
if ($len == 126) {
|
||||||
|
$len = (ord($frame[$header+0]) << 8)
|
||||||
|
| (ord($frame[$header+1]));
|
||||||
|
$header += 2;
|
||||||
|
} elseif ($len == 127) {
|
||||||
|
$len = (ord($frame[$header+0]) << 24)
|
||||||
|
| (ord($frame[$header+1]) << 16)
|
||||||
|
| (ord($frame[$header+2]) << 8)
|
||||||
|
| (ord($frame[$header+3]));
|
||||||
|
$header += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Now for the masking
|
||||||
|
if ($masked) {
|
||||||
|
$mask = substr($frame, $header, 4);
|
||||||
|
$header += 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and unmask payload
|
||||||
|
$payload = substr($frame, $header, $len);
|
||||||
|
if ($masked) {
|
||||||
|
$payload = $this->mask($payload, $mask);
|
||||||
|
$decoded['mask'] = $mask;
|
||||||
|
}
|
||||||
|
|
||||||
|
$decoded['length'] = $len;
|
||||||
|
$decoded['payload'] = $payload;
|
||||||
|
|
||||||
|
return $decoded;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function mask(string $payload, string $mask): string
|
||||||
|
{
|
||||||
|
$payloadData = array_map("ord", str_split($payload,1));
|
||||||
|
$maskData = array_map("ord", str_split($mask,1));
|
||||||
|
|
||||||
|
//printf("Mask: %02x %02x %02x %02x\n", ...$maskData);
|
||||||
|
|
||||||
|
$unmasked = [];
|
||||||
|
for ($n = 0; $n < count($payloadData); $n++) {
|
||||||
|
$unmasked[] = $payloadData[$n] ^ $maskData[$n % 4];
|
||||||
|
}
|
||||||
|
return join("", array_map("chr", $unmasked));
|
||||||
|
}
|
||||||
|
|
||||||
|
private function hexdump($data): void
|
||||||
|
{
|
||||||
|
printf("%4d .\n", strlen($data));
|
||||||
|
$rows = str_split($data, 16);
|
||||||
|
$offs = 0;
|
||||||
|
foreach ($rows as $row) {
|
||||||
|
$h = []; $a = [];
|
||||||
|
for ($n = 0; $n < 16; $n++) {
|
||||||
|
if ($n < strlen($row)) {
|
||||||
|
$h[] = sprintf("%02x%s", ord($row[$n]), ($n==7)?" ":" ");
|
||||||
|
$a[] = sprintf("%s%s", (ctype_print($row[$n])?$row[$n]:"."), ($n==7)?" ":"");
|
||||||
|
} else {
|
||||||
|
$h[] = (($n==7)?" ":" ");
|
||||||
|
$a[] = (($n==7)?" ":" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
printf("%04x | %s | %s\n", 16 * $offs++, join("", $h), join("", $a));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -27,6 +27,8 @@ class WebSocketConnection implements WebSocketInterface
|
|||||||
|
|
||||||
private ?string $groupName = null;
|
private ?string $groupName = null;
|
||||||
|
|
||||||
|
private WebSocketCodec $codec;
|
||||||
|
|
||||||
private ?ConnectionGroup $group = null;
|
private ?ConnectionGroup $group = null;
|
||||||
|
|
||||||
private GroupManager $groupManager;
|
private GroupManager $groupManager;
|
||||||
@ -43,6 +45,9 @@ class WebSocketConnection implements WebSocketInterface
|
|||||||
|
|
||||||
public function __construct(ServerRequestInterface $request, ReadableStreamInterface $inStream, WritableStreamInterface $outStream, GroupManager $groupManager)
|
public function __construct(ServerRequestInterface $request, ReadableStreamInterface $inStream, WritableStreamInterface $outStream, GroupManager $groupManager)
|
||||||
{
|
{
|
||||||
|
// The codec is used to encode and decode frames
|
||||||
|
$this->codec = new WebSocketCodec();
|
||||||
|
|
||||||
$this->request = $request;
|
$this->request = $request;
|
||||||
$this->inStream = $inStream;
|
$this->inStream = $inStream;
|
||||||
$this->outStream = $outStream;
|
$this->outStream = $outStream;
|
||||||
@ -54,42 +59,10 @@ class WebSocketConnection implements WebSocketInterface
|
|||||||
private function onWebSocketData($data)
|
private function onWebSocketData($data)
|
||||||
{
|
{
|
||||||
|
|
||||||
// Keep track of the number of bytes in the header
|
$decoded = $this->codec->decode($data);
|
||||||
$header = 2;
|
$opcode = $decoded['opcode'];
|
||||||
|
$final = $decoded['final'];
|
||||||
// Peek at the first byte, holding flags and opcode
|
$payload = $decoded['payload'];
|
||||||
$byte0 = ord($data[0]);
|
|
||||||
$final = !!($byte0 & 0x80);
|
|
||||||
$opcode = $byte0 & 0x0F;
|
|
||||||
// Peek at the second byte, holding mask bit and len
|
|
||||||
$byte1 = ord($data[1]);
|
|
||||||
$masked = !!($byte1 & 0x80);
|
|
||||||
$len = $byte1 & 0x7F;
|
|
||||||
|
|
||||||
// Read extended length if present
|
|
||||||
if ($len == 126) {
|
|
||||||
$len = (ord($data[$header+0]) << 8)
|
|
||||||
| (ord($data[$header+1]));
|
|
||||||
$header += 2;
|
|
||||||
} elseif ($len == 127) {
|
|
||||||
$len = (ord($data[$header+0]) << 24)
|
|
||||||
| (ord($data[$header+1]) << 16)
|
|
||||||
| (ord($data[$header+2]) << 8)
|
|
||||||
| (ord($data[$header+3]));
|
|
||||||
$header += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Now for the masking
|
|
||||||
if ($masked) {
|
|
||||||
$mask = substr($data, $header, 4);
|
|
||||||
$header += 4;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and unmask payload
|
|
||||||
$payload = substr($data, $header, $len);
|
|
||||||
if ($masked) {
|
|
||||||
$payload = $this->unmask($payload, $mask);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!$final) {
|
if (!$final) {
|
||||||
if ($this->bufferedOp === null) {
|
if ($this->bufferedOp === null) {
|
||||||
@ -111,7 +84,12 @@ class WebSocketConnection implements WebSocketInterface
|
|||||||
|
|
||||||
switch ($opcode) {
|
switch ($opcode) {
|
||||||
case self::OP_PING:
|
case self::OP_PING:
|
||||||
$this->sendPong();
|
$this->sendPong($payload);
|
||||||
|
return;
|
||||||
|
case self::OP_PONG:
|
||||||
|
return;
|
||||||
|
case self::OP_CLOSE:
|
||||||
|
// TODO implement
|
||||||
return;
|
return;
|
||||||
case self::OP_CONTINUATION:
|
case self::OP_CONTINUATION:
|
||||||
$this->buffer .= $payload;
|
$this->buffer .= $payload;
|
||||||
@ -125,24 +103,9 @@ class WebSocketConnection implements WebSocketInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function unmask(string $payload, string $mask): string
|
private function sendPong(string $data): void
|
||||||
{
|
{
|
||||||
$payloadData = array_map("ord", str_split($payload,1));
|
$this->send(self::OP_PONG, $data, true);
|
||||||
$maskData = array_map("ord", str_split($mask,1));
|
|
||||||
|
|
||||||
//printf("Mask: %02x %02x %02x %02x\n", ...$maskData);
|
|
||||||
|
|
||||||
$unmasked = [];
|
|
||||||
for ($n = 0; $n < count($payloadData); $n++) {
|
|
||||||
$unmasked[] = $payloadData[$n] ^ $maskData[$n % 4];
|
|
||||||
//printf("%02x ^ %02x = %02x %s\n", $payloadData[$n], $maskData[$n%4], $payloadData[$n]^$maskData[$n%4], chr($payloadData[$n]^$maskData[$n%4]));
|
|
||||||
}
|
|
||||||
return join("", array_map("chr", $unmasked));
|
|
||||||
}
|
|
||||||
|
|
||||||
private function sendPong(): void
|
|
||||||
{
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setGroup(?string $name): void
|
public function setGroup(?string $name): void
|
||||||
@ -221,17 +184,15 @@ class WebSocketConnection implements WebSocketInterface
|
|||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
public function send(int $opcode, string $data, bool $final = true)
|
public function send(int $opcode, string $data, bool $final = true, bool $masked = false)
|
||||||
{
|
{
|
||||||
$frame = chr(($final?0x80:0x00) | ($opcode & 0xF));
|
|
||||||
|
|
||||||
$len = strlen($data);
|
$frame = $this->codec->encode([
|
||||||
if ($len > 126) {
|
'opcode' => $opcode,
|
||||||
$frame .= chr(0x7E) . chr(($len >> 8) & 0xFF) . chr($len & 0xFF);
|
'payload' => $data,
|
||||||
} else {
|
'final' => $final,
|
||||||
$frame .= chr($len);
|
'masked' => $masked
|
||||||
}
|
]);
|
||||||
$frame .= $data;
|
|
||||||
|
|
||||||
$this->outStream->write($frame);
|
$this->outStream->write($frame);
|
||||||
|
|
||||||
|
100
tests/WebSocketCodecTest.php
Normal file
100
tests/WebSocketCodecTest.php
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace NoccyLabs\React\WebSocket;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
|
||||||
|
#[CoversClass(WebSocketCodec::class)]
|
||||||
|
class WebSocketCodecTest extends \PHPUnit\Framework\TestCase
|
||||||
|
{
|
||||||
|
|
||||||
|
public function testEncodingFrames()
|
||||||
|
{
|
||||||
|
$codec = new WebSocketCodec();
|
||||||
|
|
||||||
|
$msg = $codec->encode([
|
||||||
|
'opcode'=>WebSocketCodec::OP_PING,
|
||||||
|
'payload'=>"ping"
|
||||||
|
]);
|
||||||
|
$this->assertEquals("\x89\x04ping", $msg);
|
||||||
|
|
||||||
|
$msg = $codec->encode([
|
||||||
|
'opcode'=>WebSocketCodec::OP_FRAME_TEXT,
|
||||||
|
'payload'=>"abcdefgh"]);
|
||||||
|
$this->assertEquals("\x81\x08abcdefgh", $msg);
|
||||||
|
|
||||||
|
$msg = $codec->encode([
|
||||||
|
'opcode'=>WebSocketCodec::OP_FRAME_TEXT,
|
||||||
|
'payload'=>"abcdefgh",
|
||||||
|
'masked'=>true,
|
||||||
|
'mask'=>"\x00\x00\x00\x00"
|
||||||
|
]);
|
||||||
|
$this->assertEquals("\x81\x88\x00\x00\x00\x00abcdefgh", $msg);
|
||||||
|
|
||||||
|
$msg = $codec->encode([
|
||||||
|
'opcode'=>WebSocketCodec::OP_FRAME_TEXT,
|
||||||
|
'payload'=>"abcdefgh",
|
||||||
|
'masked'=>true,
|
||||||
|
'mask'=>"\x00\xFF\x00\xFF"
|
||||||
|
]);
|
||||||
|
$this->assertEquals("\x81\x88\x00\xFF\x00\xFFa\x9dc\x9be\x99g\x97", $msg);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testDecodingFrames()
|
||||||
|
{
|
||||||
|
$codec = new WebSocketCodec();
|
||||||
|
|
||||||
|
$msg = $codec->decode("\x89\x04ping");
|
||||||
|
$this->assertEquals([
|
||||||
|
'opcode'=>WebSocketCodec::OP_PING,
|
||||||
|
'payload'=>"ping",
|
||||||
|
'final'=>true,
|
||||||
|
'rsv1'=>false,
|
||||||
|
'rsv2'=>false,
|
||||||
|
'rsv3'=>false,
|
||||||
|
'length'=>4,
|
||||||
|
'masked'=>false
|
||||||
|
], $msg);
|
||||||
|
|
||||||
|
$msg = $codec->decode("\x81\x08abcdefgh");
|
||||||
|
$this->assertEquals([
|
||||||
|
'opcode'=>WebSocketCodec::OP_FRAME_TEXT,
|
||||||
|
'payload'=>"abcdefgh",
|
||||||
|
'final'=>true,
|
||||||
|
'rsv1'=>false,
|
||||||
|
'rsv2'=>false,
|
||||||
|
'rsv3'=>false,
|
||||||
|
'length'=>8,
|
||||||
|
'masked'=>false
|
||||||
|
], $msg);
|
||||||
|
|
||||||
|
$msg = $codec->decode("\x81\x88\x00\x00\x00\x00abcdefgh");
|
||||||
|
$this->assertEquals([
|
||||||
|
'opcode'=>WebSocketCodec::OP_FRAME_TEXT,
|
||||||
|
'payload'=>"abcdefgh",
|
||||||
|
'final'=>true,
|
||||||
|
'rsv1'=>false,
|
||||||
|
'rsv2'=>false,
|
||||||
|
'rsv3'=>false,
|
||||||
|
'length'=>8,
|
||||||
|
'masked'=>true,
|
||||||
|
'mask'=>"\x00\x00\x00\x00"
|
||||||
|
], $msg);
|
||||||
|
|
||||||
|
$msg = $codec->decode("\x81\x88\x00\xFF\x00\xFFa\x9dc\x9be\x99g\x97");
|
||||||
|
$this->assertEquals([
|
||||||
|
'opcode'=>WebSocketCodec::OP_FRAME_TEXT,
|
||||||
|
'payload'=>"abcdefgh",
|
||||||
|
'final'=>true,
|
||||||
|
'rsv1'=>false,
|
||||||
|
'rsv2'=>false,
|
||||||
|
'rsv3'=>false,
|
||||||
|
'length'=>8,
|
||||||
|
'masked'=>true,
|
||||||
|
'mask'=>"\x00\xFF\x00\xFF"
|
||||||
|
], $msg);
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user