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 { |
|
|
|
|
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) { |
|
|
|
|
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) { |
|
|
|
|
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 { |
|
|
|
|
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 { |
|
|
|
|
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
|
|
|
} |
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.