Skip to content

Commit 7fc5ab7

Browse files
author
Micilini Roll
committed
phase 02: implement websocket protocol core
1 parent e45613d commit 7fc5ab7

9 files changed

Lines changed: 504 additions & 0 deletions

File tree

README.zip

42.2 KB
Binary file not shown.

src/Protocol/CloseCode.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Micilini\PhpSockets\Protocol;
6+
7+
enum CloseCode: int
8+
{
9+
case NORMAL_CLOSURE = 1000;
10+
case GOING_AWAY = 1001;
11+
case PROTOCOL_ERROR = 1002;
12+
case UNSUPPORTED_DATA = 1003;
13+
case NO_STATUS_RECEIVED = 1005;
14+
case ABNORMAL_CLOSURE = 1006;
15+
case INVALID_FRAME_PAYLOAD_DATA = 1007;
16+
case POLICY_VIOLATION = 1008;
17+
case MESSAGE_TOO_BIG = 1009;
18+
case MANDATORY_EXTENSION = 1010;
19+
case INTERNAL_SERVER_ERROR = 1011;
20+
}

src/Protocol/Frame.php

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Micilini\PhpSockets\Protocol;
6+
7+
final readonly class Frame
8+
{
9+
public function __construct(
10+
public bool $fin,
11+
public Opcode $opcode,
12+
public string $payload = '',
13+
public bool $masked = false,
14+
) {
15+
$payloadLength = strlen($this->payload);
16+
17+
if ($this->opcode->isControl() && !$this->fin) {
18+
throw new ProtocolException('Control frames must not be fragmented.');
19+
}
20+
21+
if ($this->opcode->isControl() && $payloadLength > 125) {
22+
throw new ProtocolException('Control frame payload cannot be larger than 125 bytes.');
23+
}
24+
}
25+
26+
public static function text(string $payload): self
27+
{
28+
return new self(true, Opcode::TEXT, $payload);
29+
}
30+
31+
public static function binary(string $payload): self
32+
{
33+
return new self(true, Opcode::BINARY, $payload);
34+
}
35+
36+
public static function close(string $payload = ''): self
37+
{
38+
return new self(true, Opcode::CLOSE, $payload);
39+
}
40+
41+
public static function ping(string $payload = ''): self
42+
{
43+
return new self(true, Opcode::PING, $payload);
44+
}
45+
46+
public static function pong(string $payload = ''): self
47+
{
48+
return new self(true, Opcode::PONG, $payload);
49+
}
50+
}

src/Protocol/FrameCodec.php

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Micilini\PhpSockets\Protocol;
6+
7+
use InvalidArgumentException;
8+
9+
final readonly class FrameCodec
10+
{
11+
public function __construct(private int $maxPayloadBytes = 65536)
12+
{
13+
if ($this->maxPayloadBytes < 1) {
14+
throw new InvalidArgumentException('Maximum payload size must be greater than zero.');
15+
}
16+
}
17+
18+
public function decode(string $data, bool $fromClient = true): Frame
19+
{
20+
if (strlen($data) < 2) {
21+
throw new ProtocolException('Incomplete WebSocket frame header.');
22+
}
23+
24+
$firstByte = ord($data[0]);
25+
$secondByte = ord($data[1]);
26+
$fin = ($firstByte & 0x80) === 0x80;
27+
$reservedBits = $firstByte & 0x70;
28+
29+
if ($reservedBits !== 0) {
30+
throw new ProtocolException('Reserved WebSocket frame bits are not supported.');
31+
}
32+
33+
$opcode = Opcode::tryFrom($firstByte & 0x0F);
34+
35+
if (!$opcode instanceof Opcode) {
36+
throw new ProtocolException('Unsupported WebSocket opcode.');
37+
}
38+
39+
$masked = ($secondByte & 0x80) === 0x80;
40+
41+
if ($fromClient && !$masked) {
42+
throw new ProtocolException('Client WebSocket frames must be masked.');
43+
}
44+
45+
$payloadLength = $secondByte & 0x7F;
46+
$offset = 2;
47+
48+
if ($payloadLength === 126) {
49+
$this->assertAvailableBytes($data, $offset, 2);
50+
$lengthParts = unpack('nlength', substr($data, $offset, 2));
51+
52+
if ($lengthParts === false) {
53+
throw new ProtocolException('Invalid WebSocket payload length.');
54+
}
55+
56+
$payloadLength = (int) $lengthParts['length'];
57+
$offset += 2;
58+
} elseif ($payloadLength === 127) {
59+
$this->assertAvailableBytes($data, $offset, 8);
60+
$parts = unpack('Nhigh/Nlow', substr($data, $offset, 8));
61+
62+
if ($parts === false) {
63+
throw new ProtocolException('Invalid WebSocket payload length.');
64+
}
65+
66+
if ((int) $parts['high'] !== 0) {
67+
throw new ProtocolException('WebSocket payload length is too large.');
68+
}
69+
70+
$payloadLength = (int) $parts['low'];
71+
$offset += 8;
72+
}
73+
74+
if ($payloadLength > $this->maxPayloadBytes) {
75+
throw new ProtocolException('WebSocket payload exceeds the configured maximum size.');
76+
}
77+
78+
if ($opcode->isControl()) {
79+
if (!$fin) {
80+
throw new ProtocolException('Control frames must not be fragmented.');
81+
}
82+
83+
if ($payloadLength > 125) {
84+
throw new ProtocolException('Control frame payload cannot be larger than 125 bytes.');
85+
}
86+
}
87+
88+
$maskingKey = '';
89+
90+
if ($masked) {
91+
$this->assertAvailableBytes($data, $offset, 4);
92+
$maskingKey = substr($data, $offset, 4);
93+
$offset += 4;
94+
}
95+
96+
$this->assertAvailableBytes($data, $offset, $payloadLength);
97+
$payload = substr($data, $offset, $payloadLength);
98+
99+
if ($masked) {
100+
$payload = self::applyMask($payload, $maskingKey);
101+
}
102+
103+
return new Frame($fin, $opcode, $payload, $masked);
104+
}
105+
106+
public function encode(Frame $frame, bool $mask = false): string
107+
{
108+
$payload = $frame->payload;
109+
$payloadLength = strlen($payload);
110+
111+
if ($payloadLength > $this->maxPayloadBytes) {
112+
throw new ProtocolException('WebSocket payload exceeds the configured maximum size.');
113+
}
114+
115+
$firstByte = ($frame->fin ? 0x80 : 0x00) | $frame->opcode->value;
116+
$header = chr($firstByte);
117+
$maskBit = $mask ? 0x80 : 0x00;
118+
119+
if ($payloadLength <= 125) {
120+
$header .= chr($maskBit | $payloadLength);
121+
} elseif ($payloadLength <= 65535) {
122+
$header .= chr($maskBit | 126) . pack('n', $payloadLength);
123+
} else {
124+
$header .= chr($maskBit | 127) . pack('NN', 0, $payloadLength);
125+
}
126+
127+
if (!$mask) {
128+
return $header . $payload;
129+
}
130+
131+
$maskingKey = random_bytes(4);
132+
133+
return $header . $maskingKey . self::applyMask($payload, $maskingKey);
134+
}
135+
136+
private function assertAvailableBytes(string $data, int $offset, int $neededBytes): void
137+
{
138+
if (strlen($data) < $offset + $neededBytes) {
139+
throw new ProtocolException('Incomplete WebSocket frame payload.');
140+
}
141+
}
142+
143+
private static function applyMask(string $payload, string $maskingKey): string
144+
{
145+
$result = '';
146+
$payloadLength = strlen($payload);
147+
148+
for ($index = 0; $index < $payloadLength; $index++) {
149+
$result .= $payload[$index] ^ $maskingKey[$index % 4];
150+
}
151+
152+
return $result;
153+
}
154+
}

src/Protocol/Handshake.php

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Micilini\PhpSockets\Protocol;
6+
7+
final class Handshake
8+
{
9+
private const WEBSOCKET_GUID = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
10+
11+
public static function acceptKey(string $key): string
12+
{
13+
return base64_encode(sha1($key . self::WEBSOCKET_GUID, true));
14+
}
15+
16+
/**
17+
* @return array<string, string>
18+
*/
19+
public static function parseRequestHeaders(string $request): array
20+
{
21+
$headers = [];
22+
$lines = preg_split('/\r\n|\n|\r/', $request) ?: [];
23+
24+
foreach ($lines as $line) {
25+
if (!str_contains($line, ':')) {
26+
continue;
27+
}
28+
29+
[$name, $value] = explode(':', $line, 2);
30+
$headers[strtolower(trim($name))] = trim($value);
31+
}
32+
33+
return $headers;
34+
}
35+
36+
/**
37+
* @return array<string, string>
38+
*/
39+
public static function validateRequest(string $request): array
40+
{
41+
$headers = self::parseRequestHeaders($request);
42+
43+
if (strtolower($headers['upgrade'] ?? '') !== 'websocket') {
44+
throw new ProtocolException('Invalid WebSocket upgrade header.');
45+
}
46+
47+
if (!str_contains(strtolower($headers['connection'] ?? ''), 'upgrade')) {
48+
throw new ProtocolException('Invalid WebSocket connection header.');
49+
}
50+
51+
$key = $headers['sec-websocket-key'] ?? '';
52+
53+
if (!self::isValidClientKey($key)) {
54+
throw new ProtocolException('Invalid WebSocket client key.');
55+
}
56+
57+
if (($headers['sec-websocket-version'] ?? '13') !== '13') {
58+
throw new ProtocolException('Unsupported WebSocket version.');
59+
}
60+
61+
return $headers;
62+
}
63+
64+
public static function response(string $request): string
65+
{
66+
$headers = self::validateRequest($request);
67+
$accept = self::acceptKey($headers['sec-websocket-key']);
68+
69+
return "HTTP/1.1 101 Switching Protocols\r\n"
70+
. "Upgrade: websocket\r\n"
71+
. "Connection: Upgrade\r\n"
72+
. "Sec-WebSocket-Accept: {$accept}\r\n\r\n";
73+
}
74+
75+
private static function isValidClientKey(string $key): bool
76+
{
77+
if ($key === '') {
78+
return false;
79+
}
80+
81+
$decoded = base64_decode($key, true);
82+
83+
return is_string($decoded) && strlen($decoded) === 16;
84+
}
85+
}

src/Protocol/Opcode.php

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Micilini\PhpSockets\Protocol;
6+
7+
enum Opcode: int
8+
{
9+
case CONTINUATION = 0x0;
10+
case TEXT = 0x1;
11+
case BINARY = 0x2;
12+
case CLOSE = 0x8;
13+
case PING = 0x9;
14+
case PONG = 0xA;
15+
16+
public function isControl(): bool
17+
{
18+
return in_array($this, [self::CLOSE, self::PING, self::PONG], true);
19+
}
20+
}

src/Protocol/ProtocolException.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Micilini\PhpSockets\Protocol;
6+
7+
use RuntimeException;
8+
9+
final class ProtocolException extends RuntimeException
10+
{
11+
}

0 commit comments

Comments
 (0)