Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added README.zip
Binary file not shown.
20 changes: 20 additions & 0 deletions src/Protocol/CloseCode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Protocol;

enum CloseCode: int
{
case NORMAL_CLOSURE = 1000;
case GOING_AWAY = 1001;
case PROTOCOL_ERROR = 1002;
case UNSUPPORTED_DATA = 1003;
case NO_STATUS_RECEIVED = 1005;
case ABNORMAL_CLOSURE = 1006;
case INVALID_FRAME_PAYLOAD_DATA = 1007;
case POLICY_VIOLATION = 1008;
case MESSAGE_TOO_BIG = 1009;
case MANDATORY_EXTENSION = 1010;
case INTERNAL_SERVER_ERROR = 1011;
}
50 changes: 50 additions & 0 deletions src/Protocol/Frame.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Protocol;

final readonly class Frame
{
public function __construct(
public bool $fin,
public Opcode $opcode,
public string $payload = '',
public bool $masked = false,
) {
$payloadLength = strlen($this->payload);

if ($this->opcode->isControl() && !$this->fin) {
throw new ProtocolException('Control frames must not be fragmented.');
}

if ($this->opcode->isControl() && $payloadLength > 125) {
throw new ProtocolException('Control frame payload cannot be larger than 125 bytes.');
}
}

public static function text(string $payload): self
{
return new self(true, Opcode::TEXT, $payload);
}

public static function binary(string $payload): self
{
return new self(true, Opcode::BINARY, $payload);
}

public static function close(string $payload = ''): self
{
return new self(true, Opcode::CLOSE, $payload);
}

public static function ping(string $payload = ''): self
{
return new self(true, Opcode::PING, $payload);
}

public static function pong(string $payload = ''): self
{
return new self(true, Opcode::PONG, $payload);
}
}
154 changes: 154 additions & 0 deletions src/Protocol/FrameCodec.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Protocol;

use InvalidArgumentException;

final readonly class FrameCodec
{
public function __construct(private int $maxPayloadBytes = 65536)
{
if ($this->maxPayloadBytes < 1) {
throw new InvalidArgumentException('Maximum payload size must be greater than zero.');
}
}

public function decode(string $data, bool $fromClient = true): Frame
{
if (strlen($data) < 2) {
throw new ProtocolException('Incomplete WebSocket frame header.');
}

$firstByte = ord($data[0]);
$secondByte = ord($data[1]);
$fin = ($firstByte & 0x80) === 0x80;
$reservedBits = $firstByte & 0x70;

if ($reservedBits !== 0) {
throw new ProtocolException('Reserved WebSocket frame bits are not supported.');
}

$opcode = Opcode::tryFrom($firstByte & 0x0F);

if (!$opcode instanceof Opcode) {
throw new ProtocolException('Unsupported WebSocket opcode.');
}

$masked = ($secondByte & 0x80) === 0x80;

if ($fromClient && !$masked) {
throw new ProtocolException('Client WebSocket frames must be masked.');
}

$payloadLength = $secondByte & 0x7F;
$offset = 2;

if ($payloadLength === 126) {
$this->assertAvailableBytes($data, $offset, 2);
$lengthParts = unpack('nlength', substr($data, $offset, 2));

if ($lengthParts === false) {
throw new ProtocolException('Invalid WebSocket payload length.');
}

$payloadLength = (int) $lengthParts['length'];
$offset += 2;
} elseif ($payloadLength === 127) {
$this->assertAvailableBytes($data, $offset, 8);
$parts = unpack('Nhigh/Nlow', substr($data, $offset, 8));

if ($parts === false) {
throw new ProtocolException('Invalid WebSocket payload length.');
}

if ((int) $parts['high'] !== 0) {
throw new ProtocolException('WebSocket payload length is too large.');
}

$payloadLength = (int) $parts['low'];
$offset += 8;
}

if ($payloadLength > $this->maxPayloadBytes) {
throw new ProtocolException('WebSocket payload exceeds the configured maximum size.');
}

if ($opcode->isControl()) {
if (!$fin) {
throw new ProtocolException('Control frames must not be fragmented.');
}

if ($payloadLength > 125) {
throw new ProtocolException('Control frame payload cannot be larger than 125 bytes.');
}
}

$maskingKey = '';

if ($masked) {
$this->assertAvailableBytes($data, $offset, 4);
$maskingKey = substr($data, $offset, 4);
$offset += 4;
}

$this->assertAvailableBytes($data, $offset, $payloadLength);
$payload = substr($data, $offset, $payloadLength);

if ($masked) {
$payload = self::applyMask($payload, $maskingKey);
}

return new Frame($fin, $opcode, $payload, $masked);
}

public function encode(Frame $frame, bool $mask = false): string
{
$payload = $frame->payload;
$payloadLength = strlen($payload);

if ($payloadLength > $this->maxPayloadBytes) {
throw new ProtocolException('WebSocket payload exceeds the configured maximum size.');
}

$firstByte = ($frame->fin ? 0x80 : 0x00) | $frame->opcode->value;
$header = chr($firstByte);
$maskBit = $mask ? 0x80 : 0x00;

if ($payloadLength <= 125) {
$header .= chr($maskBit | $payloadLength);
} elseif ($payloadLength <= 65535) {
$header .= chr($maskBit | 126) . pack('n', $payloadLength);
} else {
$header .= chr($maskBit | 127) . pack('NN', 0, $payloadLength);
}

if (!$mask) {
return $header . $payload;
}

$maskingKey = random_bytes(4);

return $header . $maskingKey . self::applyMask($payload, $maskingKey);
}

private function assertAvailableBytes(string $data, int $offset, int $neededBytes): void
{
if (strlen($data) < $offset + $neededBytes) {
throw new ProtocolException('Incomplete WebSocket frame payload.');
}
}

private static function applyMask(string $payload, string $maskingKey): string
{
$result = '';
$payloadLength = strlen($payload);

for ($index = 0; $index < $payloadLength; $index++) {
$result .= $payload[$index] ^ $maskingKey[$index % 4];
}

return $result;
}
}
85 changes: 85 additions & 0 deletions src/Protocol/Handshake.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Protocol;

final class Handshake
{
private const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';

public static function acceptKey(string $key): string
{
return base64_encode(sha1($key . self::WEBSOCKET_GUID, true));
}

/**
* @return array<string, string>
*/
public static function parseRequestHeaders(string $request): array
{
$headers = [];
$lines = preg_split('/\r\n|\n|\r/', $request) ?: [];

foreach ($lines as $line) {
if (!str_contains($line, ':')) {
continue;
}

[$name, $value] = explode(':', $line, 2);
$headers[strtolower(trim($name))] = trim($value);
}

return $headers;
}

/**
* @return array<string, string>
*/
public static function validateRequest(string $request): array
{
$headers = self::parseRequestHeaders($request);

if (strtolower($headers['upgrade'] ?? '') !== 'websocket') {
throw new ProtocolException('Invalid WebSocket upgrade header.');
}

if (!str_contains(strtolower($headers['connection'] ?? ''), 'upgrade')) {
throw new ProtocolException('Invalid WebSocket connection header.');
}

$key = $headers['sec-websocket-key'] ?? '';

if (!self::isValidClientKey($key)) {
throw new ProtocolException('Invalid WebSocket client key.');
}

if (($headers['sec-websocket-version'] ?? '13') !== '13') {
throw new ProtocolException('Unsupported WebSocket version.');
}

return $headers;
}

public static function response(string $request): string
{
$headers = self::validateRequest($request);
$accept = self::acceptKey($headers['sec-websocket-key']);

return "HTTP/1.1 101 Switching Protocols\r\n"
. "Upgrade: websocket\r\n"
. "Connection: Upgrade\r\n"
. "Sec-WebSocket-Accept: {$accept}\r\n\r\n";
}

private static function isValidClientKey(string $key): bool
{
if ($key === '') {
return false;
}

$decoded = base64_decode($key, true);

return is_string($decoded) && strlen($decoded) === 16;
}
}
20 changes: 20 additions & 0 deletions src/Protocol/Opcode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Protocol;

enum Opcode: int
{
case CONTINUATION = 0x0;
case TEXT = 0x1;
case BINARY = 0x2;
case CLOSE = 0x8;
case PING = 0x9;
case PONG = 0xA;

public function isControl(): bool
{
return in_array($this, [self::CLOSE, self::PING, self::PONG], true);
}
}
11 changes: 11 additions & 0 deletions src/Protocol/ProtocolException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
<?php

declare(strict_types=1);

namespace Micilini\PhpSockets\Protocol;

use RuntimeException;

final class ProtocolException extends RuntimeException
{
}
Loading
Loading