Completed
Push — master ( 0a022c...1f28ab )
by y
01:37
created

FrameHandler::onData_CheckLength()   A

Complexity

Conditions 2
Paths 2

Size

Total Lines 9

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 9
rs 9.9666
c 0
b 0
f 0
cc 2
nc 2
nop 1
1
<?php
2
3
namespace Helix\Socket\WebSocket;
4
5
use LogicException;
6
7
/**
8
 * Interprets parsed frames from the peer, and packs and writes frames.
9
 *
10
 * TODO: Multiplex by RSV.
11
 *
12
 * TODO: Stream writing.
13
 */
14
class FrameHandler {
15
16
    /**
17
     * The message buffer for data frames.
18
     *
19
     * @var string
20
     */
21
    protected $buffer = '';
22
23
    /**
24
     * @var WebSocketClient
25
     */
26
    protected $client;
27
28
    /**
29
     * Resume opCode for the `CONTINUATION` handler.
30
     *
31
     * @var int|null
32
     */
33
    protected $continue;
34
35
    /**
36
     * Max outgoing fragment size.
37
     *
38
     * Each browser has its own standard, so this is generalized.
39
     *
40
     * Defaults to 128 KiB.
41
     *
42
     * @var int
43
     */
44
    protected $fragmentSize = 128 * 1024;
45
46
    /**
47
     * Maximum inbound message length (complete payload).
48
     *
49
     * Defaults to 10 MiB.
50
     *
51
     * @var int
52
     */
53
    protected $maxLength = 10 * 1024 * 1024;
54
55
    /**
56
     * @var FrameReader
57
     */
58
    protected $reader;
59
60
    /**
61
     * Whether binary I/O should bypass buffers.
62
     *
63
     * @var bool
64
     */
65
    protected $stream = false;
66
67
    /**
68
     * @param WebSocketClient $client
69
     */
70
    public function __construct (WebSocketClient $client) {
71
        $this->client = $client;
72
        $this->reader = new FrameReader($client);
73
    }
74
75
    /**
76
     * @return int
77
     */
78
    public function getFragmentSize (): int {
79
        return $this->fragmentSize;
80
    }
81
82
    /**
83
     * @return int
84
     */
85
    public function getMaxLength (): int {
86
        return $this->maxLength;
87
    }
88
89
    /**
90
     * @return bool
91
     */
92
    public function isStream (): bool {
93
        return $this->stream;
94
    }
95
96
    /**
97
     * Progressively receives `BINARY` data into the buffer until the payload is complete.
98
     * Passes the complete payload up to {@link WebSocketClient::onBinary()}
99
     *
100
     * When {@link $stream} is `true`, this bypasses the buffer.
101
     *
102
     * @param Frame $frame
103
     * @throws WebSocketError
104
     */
105
    protected function onBinary (Frame $frame): void {
106
        if ($this->stream) {
107
            $this->client->onBinary($frame->getPayload());
108
        }
109
        else {
110
            $this->onData_CheckLength($frame);
111
            $this->buffer .= $frame->getPayload();
112
            if ($frame->isFinal()) {
113
                $binary = $this->buffer;
114
                $this->buffer = '';
115
                $this->client->onBinary($binary);
116
            }
117
        }
118
    }
119
120
    /**
121
     * When a `CLOSE` frame is received. Calls {@link WebSocketClient::onClose()}
122
     *
123
     * https://tools.ietf.org/html/rfc6455#section-5.5.1
124
     * > If an endpoint receives a Close frame and did not previously send a
125
     * > Close frame, the endpoint MUST send a Close frame in response.  (When
126
     * > sending a Close frame in response, the endpoint typically echos the
127
     * > status code it received.)
128
     *
129
     * @param Frame $frame
130
     */
131
    protected function onClose (Frame $frame): void {
132
        $this->client->onClose($frame->getCloseCode(), $frame->getCloseReason());
133
    }
134
135
    /**
136
     * When a `CONTINUATION` frame (data fragment) is received.
137
     *
138
     * @param Frame $frame
139
     * @throws WebSocketError
140
     */
141
    protected function onContinuation (Frame $frame): void {
142
        if (!$this->continue) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->continue of type integer|null is loosely compared to false; this is ambiguous if the integer can be zero. You might want to explicitly use === null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
143
            throw new WebSocketError(
144
                Frame::CLOSE_PROTOCOL_ERROR,
145
                "Received CONTINUATION without a prior fragment.",
146
                $frame
147
            );
148
        }
149
        try {
150
            if ($this->continue === Frame::OP_TEXT) {
151
                $this->onText($frame);
152
            }
153
            else {
154
                $this->onBinary($frame);
155
            }
156
        }
157
        finally {
158
            if ($frame->isFinal()) {
159
                $this->continue = null;
160
            }
161
        }
162
    }
163
164
    /**
165
     * When a control frame is received.
166
     *
167
     * https://tools.ietf.org/html/rfc6455#section-5.4
168
     * > Control frames (see Section 5.5) MAY be injected in the middle of
169
     * > a fragmented message.
170
     *
171
     * @param Frame $frame
172
     */
173
    protected function onControl (Frame $frame): void {
174
        if ($frame->isClose()) {
175
            $this->onClose($frame);
176
        }
177
        elseif ($frame->isPing()) {
178
            $this->onPing($frame);
179
        }
180
        elseif ($frame->isPong()) {
181
            $this->onPong($frame);
182
        }
183
        else {
184
            throw new WebSocketError(Frame::CLOSE_PROTOCOL_ERROR, "Unsupported control frame.", $frame);
185
        }
186
    }
187
188
    /**
189
     * When an initial data frame (not `CONTINUATION`) is received.
190
     *
191
     * @param Frame $frame
192
     */
193
    protected function onData (Frame $frame): void {
194
        // did we get a continuation?
195
        if ($frame->isContinuation()) {
196
            $this->onContinuation($frame);
197
        }
198
        // were we expecting one?
199
        elseif ($this->continue) {
0 ignored issues
show
Bug Best Practice introduced by
The expression $this->continue of type integer|null is loosely compared to true; this is ambiguous if the integer can be zero. You might want to explicitly use !== null instead.

In PHP, under loose comparison (like ==, or !=, or switch conditions), values of different types might be equal.

For integer values, zero is a special case, in particular the following results might be unexpected:

0   == false // true
0   == null  // true
123 == false // false
123 == null  // false

// It is often better to use strict comparison
0 === false // false
0 === null  // false
Loading history...
200
            throw new WebSocketError(
201
                Frame::CLOSE_PROTOCOL_ERROR,
202
                "Received interleaved {$frame->getName()} against existing " . Frame::NAMES[$this->continue],
203
                $frame
204
            );
205
        }
206
        // the data is new
207
        else {
208
            // will we get a continuation later?
209
            if (!$frame->isFinal()) {
210
                $this->continue = $frame->getOpCode();
211
            }
212
            // handle new text
213
            if ($frame->isText()) {
214
                $this->onText($frame);
215
            }
216
            // handle new binary
217
            else {
218
                $this->onBinary($frame);
219
            }
220
        }
221
    }
222
223
    /**
224
     * Validates the message length, but only when the buffer is in use.
225
     *
226
     * @param Frame $frame
227
     * @throws WebSocketError
228
     */
229
    protected function onData_CheckLength (Frame $frame): void {
230
        if (strlen($this->buffer) + $frame->getLength() > $this->maxLength) {
231
            throw new WebSocketError(
232
                Frame::CLOSE_TOO_LARGE,
233
                "Message would exceed {$this->maxLength} bytes",
234
                $frame
235
            );
236
        }
237
    }
238
239
    /**
240
     * Called by {@link WebSocketClient} when a complete frame has been received.
241
     *
242
     * Delegates to the other handler methods using the program logic outlined in the RFC.
243
     *
244
     * Eventually calls back to the {@link WebSocketClient} when payloads are complete.
245
     *
246
     * @param Frame $frame
247
     */
248
    public function onFrame (Frame $frame): void {
249
        if ($frame->isControl()) {
250
            $this->onControl($frame);
251
        }
252
        else {
253
            $this->onData($frame);
254
        }
255
    }
256
257
    /**
258
     * When a `PING` is received. Calls {@link WebSocketClient::onPing()}
259
     *
260
     * @param Frame $frame
261
     */
262
    protected function onPing (Frame $frame): void {
263
        $this->client->onPing($frame->getPayload());
264
    }
265
266
    /**
267
     * When a `PONG` is received. Calls {@link WebSocketClient::onPong()}
268
     *
269
     * @param Frame $frame
270
     */
271
    protected function onPong (Frame $frame): void {
272
        $this->client->onPong($frame->getPayload());
273
    }
274
275
    /**
276
     * Uses {@link FrameReader} to read frames and passes them off to {@link onFrame()}
277
     */
278
    public function onReadable (): void {
279
        foreach ($this->reader->getFrames() as $frame) {
280
            $this->onFrame($frame);
281
        }
282
    }
283
284
    /**
285
     * Progressively receives `TEXT` data until the payload is complete.
286
     * Validates the complete payload as UTF-8 and passes it up to {@link WebSocketClient::onText()}
287
     *
288
     * @param Frame $frame
289
     * @throws WebSocketError
290
     */
291
    protected function onText (Frame $frame): void {
292
        $this->onData_CheckLength($frame);
293
        $this->buffer .= $frame->getPayload();
294
        if ($frame->isFinal()) {
295
            if (!mb_detect_encoding($this->buffer, 'UTF-8', true)) {
296
                throw new WebSocketError(Frame::CLOSE_BAD_DATA, "The received TEXT is not UTF-8.");
297
            }
298
            $text = $this->buffer;
299
            $this->buffer = '';
300
            $this->client->onText($text);
301
        }
302
    }
303
304
    /**
305
     * @param int $bytes
306
     * @return $this
307
     */
308
    public function setFragmentSize (int $bytes) {
309
        $this->fragmentSize = $bytes;
310
        return $this;
311
    }
312
313
    /**
314
     * @param int $bytes
315
     * @return $this
316
     */
317
    public function setMaxLength (int $bytes) {
318
        $this->maxLength = $bytes;
319
        return $this;
320
    }
321
322
    /**
323
     * @param bool $stream
324
     * @return $this
325
     */
326
    public function setStream (bool $stream) {
327
        $this->stream = $stream;
328
        return $this;
329
    }
330
331
    /**
332
     * Sends a complete message to the peer, fragmenting if needed.
333
     *
334
     * @param int $opCode
335
     * @param string $payload
336
     */
337
    public function write (int $opCode, string $payload): void {
338
        $offset = 0;
339
        $total = strlen($payload);
340
        do {
341
            $fragment = substr($payload, $offset, $this->fragmentSize);
342
            if ($offset) {
343
                $opCode = Frame::OP_CONTINUATION;
344
            }
345
            $offset += strlen($fragment);
346
            $this->writeFrame($offset >= $total, $opCode, $fragment);
347
        } while ($offset < $total);
348
    }
349
350
    /**
351
     * @param string $payload
352
     */
353
    public function writeBinary (string $payload): void {
354
        $this->write(Frame::OP_BINARY, $payload);
355
    }
356
357
    /**
358
     * @param int $code
359
     * @param string $reason
360
     */
361
    public function writeClose (int $code = Frame::CLOSE_NORMAL, string $reason = ''): void {
362
        $this->writeFrame(true, Frame::OP_CLOSE, pack('n', $code) . $reason);
363
    }
364
365
    /**
366
     * Writes a single frame.
367
     *
368
     * @param bool $final
369
     * @param int $opCode
370
     * @param string $payload
371
     */
372
    protected function writeFrame (bool $final, int $opCode, string $payload): void {
373
        if ($opCode & 0x08 and !$final) {
374
            throw new LogicException("Would have sent a fragmented control frame ({$opCode}) {$payload}");
375
        }
376
        $head = chr($final ? 0x80 | $opCode : $opCode);
377
        $length = strlen($payload);
378
        if ($length > 65535) {
379
            $head .= chr(127);
380
            $head .= pack('J', $length);
381
        }
382
        elseif ($length >= 126) {
383
            $head .= chr(126);
384
            $head .= pack('n', $length);
385
        }
386
        else {
387
            $head .= chr($length);
388
        }
389
        $this->client->write($head . $payload);
390
    }
391
392
    /**
393
     * @param string $payload
394
     */
395
    public function writePing (string $payload = ''): void {
396
        $this->writeFrame(true, Frame::OP_PING, $payload);
397
    }
398
399
    /**
400
     * @param string $payload
401
     */
402
    public function writePong (string $payload = ''): void {
403
        $this->writeFrame(true, Frame::OP_PONG, $payload);
404
    }
405
406
    /**
407
     * @param string $payload
408
     */
409
    public function writeText (string $payload): void {
410
        $this->write(Frame::OP_TEXT, $payload);
411
    }
412
}