Completed
Push — master ( f836de...b537f9 )
by y
01:15
created

FrameHandler::writeFrame()   A

Complexity

Conditions 6
Paths 4

Size

Total Lines 19

Duplication

Lines 0
Ratio 0 %

Importance

Changes 0
Metric Value
dl 0
loc 19
rs 9.0111
c 0
b 0
f 0
cc 6
nc 4
nop 3
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
class FrameHandler {
11
12
    /**
13
     * The `DATA` message buffer.
14
     *
15
     * @var string
16
     */
17
    protected $buffer = '';
18
19
    /**
20
     * @var WebSocketClient
21
     */
22
    protected $client;
23
24
    /**
25
     * Resume opCode for the `CONTINUE` handler.
26
     *
27
     * @var int|null
28
     */
29
    protected $continue;
30
31
    /**
32
     * Max outgoing fragment size.
33
     *
34
     * Each browser has its own standard, so this is generalized.
35
     *
36
     * Defaults to 128 KiB.
37
     *
38
     * @var int
39
     */
40
    protected $fragmentSize = 128 * 1024;
41
42
    /**
43
     * Maximum inbound message length.
44
     *
45
     * Defaults to 10 MiB.
46
     *
47
     * @var int
48
     */
49
    protected $maxLength = 10 * 1024 * 1024;
50
51
    /**
52
     * @param WebSocketClient $client
53
     */
54
    public function __construct (WebSocketClient $client) {
55
        $this->client = $client;
56
    }
57
58
    /**
59
     * @return int
60
     */
61
    public function getFragmentSize (): int {
62
        return $this->fragmentSize;
63
    }
64
65
    /**
66
     * @return int
67
     */
68
    public function getMaxLength (): int {
69
        return $this->maxLength;
70
    }
71
72
    /**
73
     * When a `BINARY` frame is received.
74
     *
75
     * @param Frame $binary
76
     * @throws WebSocketError
77
     */
78 View Code Duplication
    protected function onBinary (Frame $binary): void {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
79
        $this->buffer .= $binary->getPayload();
80
        if ($binary->isFinal()) {
81
            $message = $this->buffer;
82
            $this->buffer = '';
83
            $this->client->getMessageHandler()->onBinary($message);
84
        }
85
    }
86
87
    /**
88
     * When a `CLOSE` frame is received.
89
     *
90
     * https://tools.ietf.org/html/rfc6455#section-5.5.1
91
     * > If an endpoint receives a Close frame and did not previously send a
92
     * > Close frame, the endpoint MUST send a Close frame in response.  (When
93
     * > sending a Close frame in response, the endpoint typically echos the
94
     * > status code it received.)
95
     *
96
     * @param Frame $close
97
     */
98
    protected function onClose (Frame $close): void {
99
        $this->client->close($close->getCloseCode());
100
    }
101
102
    /**
103
     * When a `CONTINUE` frame (data fragment) is received.
104
     *
105
     * @param Frame $fragment
106
     * @throws WebSocketError
107
     */
108
    protected function onContinue (Frame $fragment): void {
109
        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...
110
            throw new WebSocketError(
111
                Frame::CLOSE_PROTOCOL_ERROR,
112
                "Received CONTINUE without a prior fragment.",
113
                $fragment
114
            );
115
        }
116
        try {
117
            if ($this->continue === Frame::OP_TEXT) {
118
                $this->onText($fragment);
119
            }
120
            else {
121
                $this->onBinary($fragment);
122
            }
123
        }
124
        finally {
125
            if ($fragment->isFinal()) {
126
                $this->continue = null;
127
            }
128
        }
129
    }
130
131
    /**
132
     * When a control frame is received.
133
     *
134
     * https://tools.ietf.org/html/rfc6455#section-5.4
135
     * > Control frames (see Section 5.5) MAY be injected in the middle of
136
     * > a fragmented message.
137
     *
138
     * @param Frame $control
139
     */
140
    protected function onControl (Frame $control): void {
141
        if ($control->isClose()) {
142
            $this->onClose($control);
143
        }
144
        elseif ($control->isPing()) {
145
            $this->onPing($control);
146
        }
147
        elseif ($control->isPong()) {
148
            $this->onPong($control);
149
        }
150
    }
151
152
    /**
153
     * When an initial data frame (not `CONTINUE`) is received.
154
     *
155
     * @param Frame $data
156
     */
157
    protected function onData (Frame $data): void {
158
        $this->onData_SetContinue($data);
159
        if ($data->isText()) {
160
            $this->onText($data);
161
        }
162
        elseif ($data->isBinary()) {
163
            $this->onBinary($data);
164
        }
165
    }
166
167
    /**
168
     * @param Frame $data
169
     * @throws WebSocketError
170
     */
171
    protected function onData_SetContinue (Frame $data): void {
172
        if ($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...
173
            throw new WebSocketError(
174
                Frame::CLOSE_PROTOCOL_ERROR,
175
                "Received interleaved {$data->getName()} against existing " . Frame::NAMES[$this->continue],
176
                $data
177
            );
178
        }
179
        if (!$data->isFinal()) {
180
            $this->continue = $data->getOpCode();
181
        }
182
    }
183
184
    /**
185
     * Called by {@link WebSocketClient} when a complete frame has been received.
186
     *
187
     * Delegates to the other handler methods using the control flow outlined in the RFC.
188
     *
189
     * @param Frame $frame
190
     */
191
    public function onFrame (Frame $frame): void {
192
        $this->onFrame_CheckRsv($frame);
193
        $this->onFrame_CheckLength($frame);
194
        if ($frame->isControl()) {
195
            $this->onControl($frame);
196
        }
197
        elseif ($frame->isContinue()) {
198
            $this->onContinue($frame);
199
        }
200
        else {
201
            $this->onData($frame);
202
        }
203
    }
204
205
    /**
206
     * @param Frame $frame
207
     * @throws WebSocketError
208
     */
209
    protected function onFrame_CheckLength (Frame $frame): void {
210
        if ($frame->isData()) {
211
            $length = strlen($this->buffer);
212
            if ($length + $frame->getLength() > $this->maxLength) {
213
                throw new WebSocketError(
214
                    Frame::CLOSE_TOO_LARGE,
215
                    "Message would exceed {$this->maxLength} bytes",
216
                    $frame
217
                );
218
            }
219
        }
220
    }
221
222
    /**
223
     * Throws if unknown RSV bits are received.
224
     *
225
     * @param Frame $frame
226
     * @throws WebSocketError
227
     */
228
    protected function onFrame_CheckRsv (Frame $frame): void {
229
        if ($badRsv = $frame->getRsv() & ~$this->client->getHandshake()->getRsv()) {
230
            $badRsv = str_pad(base_convert($badRsv >> 4, 10, 2), 3, '0', STR_PAD_LEFT);
231
            throw new WebSocketError(
232
                Frame::CLOSE_PROTOCOL_ERROR,
233
                "Received unknown RSV bits: 0b{$badRsv}",
234
                $frame
235
            );
236
        }
237
    }
238
239
    /**
240
     * When a `PING` frame is received.
241
     *
242
     * Automatically pongs the payload back by default.
243
     *
244
     * @param Frame $ping
245
     */
246
    protected function onPing (Frame $ping): void {
247
        $this->writePong($ping->getPayload());
248
    }
249
250
    /**
251
     * When a `PONG` frame is received.
252
     *
253
     * Does nothing by default.
254
     *
255
     * @param Frame $pong
256
     */
257
    protected function onPong (Frame $pong): void {
0 ignored issues
show
Unused Code introduced by
The parameter $pong is not used and could be removed.

This check looks from parameters that have been defined for a function or method, but which are not used in the method body.

Loading history...
258
        // stub
259
    }
260
261
    /**
262
     * When a `TEXT` frame is received.
263
     *
264
     * @param Frame $text
265
     * @throws WebSocketError
266
     */
267 View Code Duplication
    protected function onText (Frame $text): void {
0 ignored issues
show
Duplication introduced by
This method seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
268
        $this->buffer .= $text->getPayload();
269
        if ($text->isFinal()) {
270
            $message = $this->buffer;
271
            $this->buffer = '';
272
            $this->client->getMessageHandler()->onText($message);
273
        }
274
    }
275
276
    /**
277
     * @param int $bytes
278
     * @return $this
279
     */
280
    public function setFragmentSize (int $bytes) {
281
        $this->fragmentSize = $bytes;
282
        return $this;
283
    }
284
285
    /**
286
     * @param int $bytes
287
     * @return $this
288
     */
289
    public function setMaxLength (int $bytes) {
290
        $this->maxLength = $bytes;
291
        return $this;
292
    }
293
294
    /**
295
     * Sends a payload to the peer, fragmenting if needed.
296
     *
297
     * @param int $opCode
298
     * @param string $payload
299
     */
300
    public function write (int $opCode, string $payload): void {
301
        $offset = 0;
302
        $total = strlen($payload);
303
        do {
304
            $fragment = substr($payload, $offset, $this->fragmentSize);
305
            if ($offset) {
306
                $opCode = Frame::OP_CONTINUE;
307
            }
308
            $offset += strlen($fragment);
309
            $this->writeFrame($offset >= $total, $opCode, $fragment);
310
        } while ($offset < $total);
311
    }
312
313
    /**
314
     * @param string $payload
315
     */
316
    public function writeBinary (string $payload): void {
317
        $this->write(Frame::OP_BINARY, $payload);
318
    }
319
320
    /**
321
     * @param int $code
322
     * @param string $reason
323
     */
324
    public function writeClose (int $code = Frame::CLOSE_NORMAL, string $reason = ''): void {
325
        $this->writeFrame(true, Frame::OP_CLOSE, pack('n', $code) . $reason);
326
    }
327
328
    /**
329
     * Writes a single frame.
330
     *
331
     * @param bool $final
332
     * @param int $opCode
333
     * @param string $payload
334
     */
335
    protected function writeFrame (bool $final, int $opCode, string $payload): void {
336
        if ($opCode & 0x08 and !$final) {
337
            throw new LogicException("Would have sent a fragmented control frame ({$opCode}) {$payload}");
338
        }
339
        $head = chr($final ? 0x80 | $opCode : $opCode);
340
        $length = strlen($payload);
341
        if ($length > 65535) {
342
            $head .= chr(127);
343
            $head .= pack('J', $length);
344
        }
345
        elseif ($length >= 126) {
346
            $head .= chr(126);
347
            $head .= pack('n', $length);
348
        }
349
        else {
350
            $head .= chr($length);
351
        }
352
        $this->client->write($head . $payload);
353
    }
354
355
    /**
356
     * @param string $payload
357
     */
358
    public function writePing (string $payload = ''): void {
359
        $this->writeFrame(true, Frame::OP_PING, $payload);
360
    }
361
362
    /**
363
     * @param string $payload
364
     */
365
    public function writePong (string $payload = ''): void {
366
        $this->writeFrame(true, Frame::OP_PONG, $payload);
367
    }
368
369
    /**
370
     * @param string $payload
371
     */
372
    public function writeText (string $payload): void {
373
        $this->write(Frame::OP_TEXT, $payload);
374
    }
375
}