PHP Websockets Tutorial

9th October 2013   4249 views

Websockets are not fully implemented yet so it is very hard to find a decent tutorial on the internet. I once found a tutorial(phpWebSockets by Moritz Wutz and http://srchea.com/blog/2011/12/build-a-real-time-application-using-html5-websockets/) that used classes to implement a pretty good WebSockets server. However that tutorial provided the code with bugs and not a lot of explanation as to how it works and how can you add more to it. I decided that if I wanted to make my own WS server my best bet would be to take that code and upgrade it so that it works and acts as I want it to. After I got the prototype working I decided I need to share my code with others so that they won't have to go through all that trouble again. This tutorial is not meant to teach you how to write your own server, merely it explains how my code works.

Firts off we need some basic HTML structure. This is just the code the original tutorial provided striped off of the unnecessary parts.

1 <div id="wrapper">
2     <div id="container">    
3         <h1>WebSockets Client</h1>
4         <button id="disconnect">Disconnect</button>
5         <span id="status"></span>
6         <span id="ping"></span>
7         <div id="chatLog">
8         
9         </div>
10         <br />
11         <input id="text" type="text" placeholder="type in message"/>
12     </div>
13     <div id="users">
14     
15     </div>
16 </div>

Then the css which is nothing special, again almost left unchaged.

1 body{font-family:ArialHelveticasans-serif;}
2 #container{
3     border:5px solid grey;
4     width:670px;
5     margin:0 auto;
6     padding:10px;
7     float:left;
8 }
9 #users {
10     float:right;
11     width:220px;
12     border2px solid grey;
13     padding2px 0 2px 4px;
14 }
15 #chatLog{
16     padding:5px;
17     border:1px solid black;    
18     height400px;
19     overflowauto;
20 }
21 #chatLog p {
22     margin:0;
23 }
24 .event{
25     color#999999;
26 }
27 .warning{
28     font-weight:bold;
29     color#CCC;
30 }

Javascript

I used JQuery library in this project.

1 $(document).ready(function() 
2 {
3     $('#chatLog, input, button, #examples').hide();
4     if(!("WebSocket" in window)){    
5         $('<p>Oh no, you need a browser that supports WebSockets. How about <a href="http://www.google.com/chrome">Google Chrome</a>?</p>').appendTo('#container');        
6     }
7     else
8     {
9         //The user has WebSockets
10         var socket;
11         var host "ws://109.255.177.28:8080";
12         var reconnect=true;
13         var ping;
14         var pingInt;
15     
16         function wsconnect()
17         {
18             try{
19                 socket = new WebSocket(host);
20                 socket.onopen = function(){
21                     status('<p class="event">Socket Status: '+socket.readyState+' (open)');
22                     socket.send('login: '+name);
23                     pingInt setInterval(ping1000);
24                 }
25                 
26                 socket.onerror = function(error){
27                     status('<p class="event">Socket Status: '+socket.readyState+' ('+error+')');    
28                 }
29                 
30                 //receive message
31                 socket.onmessage = function(msg){
32                     var data JSON.parse(msg.data);
33                     if(data.message!=undefined)
34                     {
35                         message('<p class="message">'+data.message+'</p>');
36                     }
37                     if(data.ping!=undefined)
38                     {
39                         var time = new Date().getTime();
40                         time time ping;
41                         $('#ping').html('Ping: '+time+'ms');
42                     }
43                     if(data.users!=undefined)
44                     {
45                         var str "";
46                         var i;
47                         for(i in data.users)
48                         {
49                             str += data.users[i]+'<br />';
50                         }
51                         $('#users').html('<h2>Users loged in:</h2>'+str);
52                     }
53                 }
54                 
55                 socket.onclose = function(e){
56                     clearInterval(pingInt);
57                     if(reconnect){
58                         wsconnect();
59                     }
60                     status('<p class="event">Socket Status: '+socket.readyState+' (Closed)');
61                 }            
62                     
63             } catch(exception){
64                 message('<p>Error '+exception);
65             }
66         }
67         //End connect()    
68         
69         function ping()
70         {
71             if(reconnect)
72             {
73                 ping = new Date().getTime();
74                 socket.send('ping');
75             }
76         }
77             
78         function send()
79         {
80             var text = $('#text').val();
81             if(text==""){
82                 alert('Please enter a message');
83                 return ;    
84             }
85             try{
86                 socket.send(text);
87                 message('<p class="event">'+name+': '+text)
88             } catch(exception){
89                 message('<p class="warning">'+exception);
90             }
91             $('#text').val("");//clear text input field
92         }
93         
94         function message(msg)
95         {
96             $('#chatLog').append(msg);
97             $('#chatLog').scrollTop($('#chatLog')[0].scrollHeight);
98         }
99         
100         function status(msg)
101         {
102             $('#status').html(msg+'</p>');
103         }
104         
105         $('#text').keypress(function(event
106         {
107             if (event.keyCode == '13') {
108                 send();
109             }
110         });    
111         
112         $('#disconnect').click(function()
113         {
114             reconnect=false;//dont try to reconnect again
115             socket.close();
116         });
117
118         var name='';
119         while(name=='')
120             name prompt("Your name:");
121         if(name!=null)
122         {
123             $('#chatLog, input, button, #examples').fadeIn("fast");
124             wsconnect();
125         }
126     }
127 });

Ok, so there's our client. First off we check if the browser supports WebSockets. We obviously need an address to which we will connect: var host = "ws://109.255.177.28:8080" ; The function 'wsconnect' is the main function, when you call it the browser will try to estabilish connection. Function named 'send' is called when there is a message to be send from the user to the server and 'socket.send(text);' takes care of that, the rest is just checking if there's any input. The other functions are just for easier putting/getting data on our website and they are self explanatory. socket.onmessage = function( msg ){ That method is called whenever a message comes from the server. I use JSON to pass data as it is the easient way to manage data transmitted between PHP and JavaScript IMHO. 'data' contains the data from server already encoded. The if statement checks what kind of data was received and carries out approbiate action(plain message, ping response, info about logged on users). socket.onclose = function(e){ The onclose method is called if the connection has been terminated. If the connection was not terminated by user it tries to connect again.

PHP

The socket.class.php file and the daemon file was almost left untouched by me so I will not bother with explanation.

I will only discuss the most important parts of the main file.

1 private function run()
2 {
3     while(true)
4     {
5         $this->i++;
6         # because socket_select gets the sockets it should watch from $changed_sockets
7         # and writes the changed sockets to that array we have to copy the allsocket array
8         # to keep our connected sockets list
9         $changed_sockets $this->allsockets;
10
11         # blocks execution until data is received from any socket
12         //wait 1ms(1000us) - should theoretically put less pressure on the cpu
13         $num_sockets socket_select($changed_sockets,$write=NULL,$exceptions=NULL,0,1000);
14
15         # foreach changed socket...
16         foreach( $changed_sockets as $socket )
17         {
18             # master socket changed means there is a new socket request
19             if( $socket==$this->master )
20             {
21                 # if accepting new socket fails
22                 if( ($client=socket_accept($this->master)) < )
23                 {
24                     $this->console('socket_accept() failed: reason: ' socket_strerror(socket_last_error($client)));
25                     continue;
26                 }
27                 # if it is successful push the client to the allsockets array
28                 else
29                 {
30                     $this->allsockets[] = $client;
31
32                     # using array key from allsockets array, is that ok?
33                     # i want to avoid the often array_search calls
34                     $socket_index array_search($client,$this->allsockets);
35                     $this->clients[$socket_index] = new stdClass;
36                     $this->clients[$socket_index]->socket_id $client;
37
38                     $this->console($client ' CONNECTED!');
39                 }
40             }
41             # client socket has sent data
42             else
43             {
44                 $socket_index array_search($socket,$this->allsockets);
45                 $user $this->users[$socket_index];
46
47                 # the client status changed, but theres no data ---> disconnect
48                 $bytes = @socket_recv($socket,$buffer,2048,0);
49                 if( $bytes === )
50                 {
51                     $this->disconnected($socket);
52                 }
53                 # there is data to be read
54                 else
55                 {
56                     # this is a new connection, no handshake yet
57                     if( !isset($this->handshakes[$socket_index]) )
58                     {
59                         $this->do_handshake($buffer,$socket,$socket_index);
60                     }
61                     # handshake already done, read data
62                     else
63                     {
64                         $action $this->unmask($buffer);
65                         if($action=='')
66                         {    
67                             $this->disconnected($socket);
68                             continue;
69                         }
70                         
71                         //that was some hack I forgot to properly comment and I dont know what it does
72                         if($action==chr(3).chr(233))
73                         {    
74                             $this->disconnected($socket);
75                             continue;
76                         }
77                         
78                         $output = array();
79                         if(($pos=strpos($action,'login'))===&& $user=='')
80                         {
81                             $name substr($action,$pos+7);
82                             $this->users[$socket_index] = $name;
83                             $this->console('Loged in as: '.$name);
84                         }
85                         else if($action=="ping")
86                         {
87                             $output['ping'] = true;
88                             $this->send($socketjson_encode($output));
89                         }
90                         else
91                         {    
92                             $skipSockets = array($this->master,$socket);
93                             $them array_diff($this->allsockets,$skipSockets);
94                             $output['message'] = $user.': '.$action;
95                             foreach($them as $sock)
96                             {
97                                 $this->send($sock,json_encode($output));
98                             }
99                         }
100
101                     }
102                 }
103             }
104         }
105         
106         $timeDiff =  (microtime(true) - $this->lastTime)*1000;
107         //server messages
108         if($timeDiff>1000)//send messages out every 1000 ms
109         {
110             $output = array();
111             $this->lastTime microtime(true);
112             $output['message'] = "Server Message".$this->i;
113             $output['users'] = $this->users;
114             $destinSockets array_diff($this->allsockets,array($this->master));
115             foreach($destinSockets as $sock)
116             {
117                 $this->send($sockjson_encode($output));
118             }
119         }
120     }
121 }

The main method is made up of an infinite while loop. The loop has two parts, listening to the sockets and sending messages out. $num_sockets = socket_select($changed_sockets,$write=NULL,$exceptions=NULL,0,1000); This is the line that listens on all sockets until timeout or until a sockets state has changed.

Inside a foreach loop it checks wheather the socket is a new connection or an existing one. If new it tries to estabilish a connection, if existing it takes the data off it. If there was no data received then the connection is wrong so disconnect, else check if there is a handshake already done with this socket, if not then this must be a handshake data. If the handshake has already been done then the data received must be an ordinary data with which you can do whatever you want but first you need to decode it: $action = $this->unmask($buffer); I will discuss the 'unmask' method in a second.

The second part of the while loop broadcasts the data. We don't want the badwidth to be flooded with our messages so I wrote a simple if statement that checks if there has passes more than 1ms since last time. The destination sockets must be all sockets except the master socket.

1 private function do_handshake($buffer,$socket,$socket_index)
2 {
3     list($resource,$host,$origin,$key) = $this->getheaders($buffer);
4     $retkey base64_encode(sha1($key."258EAFA5-E914-47DA-95CA-C5AB0DC85B11",true));
5     $upgrade  "HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: {$retkey}\r\n\r\n";
6     $this->handshakes[$socket_index] = true;
7     socket_write($socket,$upgrade,strlen($upgrade));
8     $this->console("Done handshaking...\n");
9 }

The hanshake method is as simple as that. You extract the data from the handhsake that the client has sent. The 'getheaders' does that by using simple string methods. You take the key part and concatenate with the other part of the key. The second part of the key is always the same and is specified as a part of the WS specs. You need to sha1 and then base64 encode the whole thing. As 'Sec-WebSocket-Accept' you put the return key you just generated create a header for it and send the handshake back to the browser. You could send more info in the header but for that I suggest reading the specs.

The messages that are transmitted back and forth need to be encoded and decoded. There is a very complicated way that the specs say you need to obey. See this SO question

1 private function unmask($payload
2 {
3     $length ord($payload[1]) & 127;
4  
5     if($length == 126) {
6         $masks substr($payload44);
7         $data substr($payload8);
8         $len = (ord($payload[2]) << 8) + ord($payload[3]);
9     }
10     elseif($length == 127) {
11         $masks substr($payload104);
12         $data substr($payload14);
13         $len = (ord($payload[2]) << 56) + (ord($payload[3]) << 48) + (ord($payload[4]) << 40) + (ord($payload[5]) << 32) + 
14             (ord($payload[6]) << 24) + (ord($payload[7]) << 16) + (ord($payload[8]) << 8) + ord($payload[9]);
15     }
16     else {
17         $masks substr($payload24);
18         $data substr($payload6);
19         $len $length;
20     }
21  
22     $text '';
23     for ($i 0$i $len; ++$i) {
24         $text .= $data[$i] ^ $masks[$i%4];
25     }
26     return $text;
27 }

The character at position 1 (i.e., the second byte of the message) is a combination of one bit denoting whether the message is masked (i.e., encoded) and seven bits denoting the payload (i.e., data) length. The first bit must always be 1 according to the specs because the message must always be masked. The next seven bits when converted to a decimal give you the length. However, because you can only go up to 127 with seven bits, there must be a way of getting the length of messages that are longer than that. According to the specs, if the message is longer than 125 characters then the seven bits marking the payload length must be 126, and the actual length is then stored in the 3rd and 4th bytes in the message. If the message is longer than 65,535 characters, then the seven bits marking the payload length must be 127, and the actual length is stored in the 3rd through 8th bytes. In order to convert the binary bits that make up the bytes into a decimal for the length, you have to use the bitwise operators as I have done in the solution linked above and in order to get the length stored in the last seven bits of the 2nd byte in the message, you need to use logical AND (i.e., the & operator) to essentially ignore the 1 set for the first bit used to denoted whether the message is masked or not. For details on how data is framed, please see the official WebSocket specs. Also, if you need info on what bitwise operators are and how to use them in PHP, please see the following: php.net/manual/en/language.operators.bitwise.php – HartleySan

1 private function encode($text
2 {
3     // 0x1 text frame (FIN + opcode)
4     $b1 0x80 | (0x1 0x0f);
5     $length strlen($text);
6     
7     if($length <= 125)
8         $header pack('CC'$b1$length);
9     elseif($length 125 && $length 65536)
10         $header pack('CCS'$b1126$length);
11     elseif($length >= 65536)
12         $header pack('CCN'$b1127$length);
13  
14     return $header.$text;
15 }

Encoding the data goes exactly the same way. The first byte is a never changing byte (in my case I set it in '$b1'). The second byte will either be 126, 127 or the length of the text if less than 126. You create a string out of those bytes and put it in front of your message.

Extras:

To run the WS server from a web browser on LINUX you can use this code:

1 $pid exec('pidof php');
2 exec('kill -9 '.$pid);
3 exec('php -q server/startDaemon.php > output.txt &');

Just save this code as start.php and run this file in the browser. To stop use the same code without line 3. You can also run the startDaemon.php file in CLI.