├── .github └── FUNDING.yml ├── .gitignore ├── README.md ├── canvas.php ├── db-get-state.sql ├── db-new-client.sql ├── db-set-text.sql ├── db-update-cursor.sql ├── db.sql ├── index.html ├── script.js ├── server.php ├── state.php ├── style.css └── ws.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [g105b] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /text.db 2 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | An experiment with WebSockets and the human condition. 2 | ====================================================== 3 | 4 | I wanted to learn how to use WebSockets in pure PHP and JavaScript, so I came up with the simplest project to build that would only take a few hours to put together. 5 | 6 | The concept: a WebSocket server that communicates state between all connected clients, and persists state to a SQLite database. The client consists of an infinitely scrollable 2D grid that can have text typed into it. 7 | 8 | The human condition: I am fully aware that this will be abused, because I wanted to keep the communication channels completely anonymous. It would be impossible to censor input completely anyway, so let's just see what mess this will produce. Wait, it's not mess - it's **art**. 9 | 10 | ~~You can view the live project at: https://www.walloftext.art~~ I had to take the service offline due to abuse, but I'll bring it back one rainy day. 11 | 12 | Getting set up. 13 | --------------- 14 | 15 | You can run this project locally if you have PHP 8 installed with the `sqlite` extension. Nothing else is required to run - I've purposefully ignored any build tools, even Composer. All the files you need are in the repository. 16 | 17 | To serve the project in a web browser, use any server you are familiar with or just run `php -S 0.0.0.0:8080` to serve the directory using PHP's inbuilt server - then navigate to http://localhost:8080 in your browser. 18 | 19 | You'll be able to click around and type into the grid, but until the WebSocket server is running, nothing will persist. To run the WebSocket server, while the web server is still running, run `php ws.php`. This will run a new server, listening on port 10500. 20 | 21 | The database runs using SQLite, so instead of having a separate database server running, the database stores itself within the `text.db` file. This file will be created automatically, and if there are no "tables" contained within it, the start of `ws.php` will run `db.sql` which contains the `create table` commands. 22 | 23 | If something isn't working for you, [open an issue](https://github.com/g105b/text/issues), and I'll be happy to help. 24 | 25 | *** 26 | 27 | How WebSockets work. 28 | -------------------- 29 | 30 | There are three main components of a WebSocket connection, from a PHP developer's perspective. 31 | 32 | 1) The client - some JavaScript that makes a connection to an endpoint, like `ws://example.com/ws.php`, which can send and receive text messages once connected. 33 | 2) The endpoint - the PHP script that the client connects to. Note: the connection is just a plain HTTP connection at first, like any other GET request, but the script needs to send some headers to "upgrade" the connection to a persistent WebSocket connection that will be trusted by the client. 34 | 3) The loop - once the client is connected to the endpoint, the script needs to stay running forever in an infinite loop. Within the loop, new messages can be read by PHP, or messages can be sent to individual client connections. This bit is probably the most different from typical PHP development because of the long-running script, but there's no reason PHP can't do this kind of task really well. 35 | 36 | ### The client. 37 | 38 | The client starts life as `index.html`, viewed in the browser like any other webpage. There's not too much to it, just the basic `` elements and a single `` where the grid is to be drawn. 39 | 40 | `script.js` is loaded in the head with the `defer` attribute that is the modern day replacement of the document.ready function (deferred scripts will load as soon as possible, but only execute when the document is ready). 41 | 42 | At the top of `script.js`, I have defined the `WebSocket` object, then any variables that will be used for the drawing and interaction with the 2D grid. In fact, the majority of the JavaScript in this project is just to draw the text in the canvas, and control scrolling around, etc. The only WebSocket-specific code is right at the bottom of the script: `socket.onmessage` is a function that updates the `data` object with the text coordinates as they are sent from the server. 43 | 44 | ### The endpoint. 45 | 46 | `ws.php` is a simple looking script. It's the bootstrap of the server-side code. It constructs any objects that are required for this project to work, then injects them where needed. 47 | 48 | PDO is used to persist data to the `text.db` file. Then a `Server` object is created, where most of the work will happen, along with a `Canvas` for representing the grid of text and `State` for persisting the Canvas to the Database. 49 | 50 | The last step of the endpoint is the initiate the infinite loop. Once the script enters the loop, it will never exit until the script is terminated. Three functions are passed in which control the behaviour of a new client connection, new client data, and getting the latest data from the current State. 51 | 52 | The actual initialisation of the WebSocket connection is done within the `Server` object, which is also where the loop lives. 53 | 54 | ### The loop. 55 | 56 | `server.php` initialises the WebSocket connection and handles the two-way communication, along with keeping track of all the connected clients. 57 | 58 | In the constructor, a new `Socket` is created that listens on port 10500. 59 | 60 | The loop function is simply an infinite loop (`while(true)`) that constantly calls the `tick` function, after breathing for a few milliseconds. 61 | 62 | Within the `tick` function, any new clients are handled first. When there's a new client, the incoming connection looks like a standard HTTP request. To satisfy the client connection, a particular type of response needs to be sent back (explained in more detail within the code comments). 63 | 64 | Successfully connected clients are stored in the `$clientSocketArray` variable, which makes it easy to loop over all clients to check for new messages. Messages from WebSocket clients are "masked" as a security measure to help servers identify real client messages. 65 | 66 | When unmasking a message, it's important to know that more than one message from the client can be sent within the same packet of data (this is [Nagle's algorithm](https://en.wikipedia.org/wiki/Nagle%27s_algorithm)). Because the loop has a 100ms imposed delay (to keep my CPU cool), and because every character typed is sent in its own message, fast typists will notice in the browser's network inspector that more than one message can be sent within the same frame of data. 67 | 68 | This is probably where I spent the most time debugging this project, because for ages I didn't realise that more than one message can be sent within the same frame, and code snippets I had seen online all failed to mention it. Essentially, the WebSocket protocol defines that the length of the message should be sent in byte 1, but this may be less than the total number of bytes in the message. This is all done within the `unmask` function, which will continue to call itself recursively until the entire frame of data is processed, returning an array of all unmasked strings. 69 | 70 | ### Database. 71 | 72 | Every character change is recorded to the database. This is probably really inefficient, but this project was intended to learn WebSockets, not produce optimal SQL. 73 | 74 | There are only two tables, `client` and `text`, and this is all persisted into a single `text.db` file using SQLite. The client table is intended to keep track of the client connections, and also allows individual client's changes to be removed (in case the inevitable graffiti is too much). The text table stores the `x` and `y` location of every `c` character at `t` timestamp for each `client` ID. This data means it's possible to replay the entire canvas, character by character. This could be fun. 75 | 76 | Final thoughts. 77 | --------------- 78 | 79 | In terms of optimisations, only the biggest wins have been implemented. For instance, the server keeps track of the timestamp of the updates it hands out to clients, so only new data is sent across the wire as it is made. However, clients receive all data on the grid as soon as they connect. I know this is only text data, but seeing as clients can only see a small section of the grid at any one time, this is probably the best place to look for future optimisations (pull requests welcome). 80 | 81 | On the client-side, only the characters that are in view are drawn to screen. It would be nice to improve things so that clients only know about the characters directly around them, but I've spent enough time on this already, so that can be for another day (never). 82 | 83 | Sponsor me? 84 | ----------- 85 | 86 | If you found this repository helpful, [please consider sponsoring me on Github Sponsors](https://github.com/sponsors/g105b). It would mean so much to me! 87 | -------------------------------------------------------------------------------- /canvas.php: -------------------------------------------------------------------------------- 1 | > A multidimensional 7 | * array representing the numbered Y rows and X columns. The nested 8 | * string value is the character at that point. 9 | */ 10 | private array $data; 11 | /** 12 | * @var array> A cache of data that is unread, 13 | * following the same format as the $data array. 14 | */ 15 | private array $newData; 16 | 17 | public function __construct() { 18 | $this->data = []; 19 | $this->newData = []; 20 | } 21 | 22 | public function setData(int $x, int $y, ?string $c):void { 23 | if(!isset($this->data[$y])) { 24 | $this->data[$y] = []; 25 | } 26 | $this->data[$y][$x] = $c; 27 | 28 | if(!isset($this->newData[$y])) { 29 | $this->newData[$y] = []; 30 | } 31 | $this->newData[$y][$x] = $c; 32 | } 33 | 34 | public function getData(bool $getNew = false):array { 35 | if($getNew) { 36 | $newData = $this->newData; 37 | $this->newData = []; 38 | return $newData; 39 | } 40 | 41 | return $this->data; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /db-get-state.sql: -------------------------------------------------------------------------------- 1 | select 2 | max(t) as t, 3 | text.x, 4 | text.y, 5 | c 6 | from 7 | text 8 | 9 | where 10 | (:timestamp is null or t > :timestamp) 11 | 12 | group by 13 | x, y 14 | -------------------------------------------------------------------------------- /db-new-client.sql: -------------------------------------------------------------------------------- 1 | insert into client( 2 | ip, 3 | port, 4 | t 5 | ) 6 | values ( 7 | :ip, 8 | :port, 9 | :timestamp 10 | ) 11 | -------------------------------------------------------------------------------- /db-set-text.sql: -------------------------------------------------------------------------------- 1 | insert or replace into text ( 2 | t, 3 | x, 4 | y, 5 | c, 6 | client 7 | ) 8 | values ( 9 | :timestamp, 10 | :x, 11 | :y, 12 | :c, 13 | :id 14 | ) 15 | on conflict(t, x, y) do update 16 | set 17 | c = :c, 18 | client = :id 19 | -------------------------------------------------------------------------------- /db-update-cursor.sql: -------------------------------------------------------------------------------- 1 | update client 2 | set 3 | x = :x, 4 | y = :y, 5 | t = :timestamp 6 | 7 | where 8 | id = :id; 9 | -------------------------------------------------------------------------------- /db.sql: -------------------------------------------------------------------------------- 1 | -- The client table represents a browser that has connected to the application. 2 | -- This allows us to render the cursor positions of everyone who is currently 3 | -- connected, identify future connections, and potentially remove the entries 4 | -- made by individual clients if necessary. 5 | create table client 6 | ( 7 | id integer not null 8 | constraint client_pk 9 | primary key autoincrement, 10 | ip text not null, 11 | port integer not null, 12 | t integer not null, 13 | x integer, 14 | y integer 15 | ); 16 | 17 | -- The IP and port are the identifiers used when new data is received by the 18 | -- server, so index them for fast retrieval of the ID. 19 | create index client_ip_index 20 | on client (ip); 21 | create index client_port_index 22 | on client (port); 23 | 24 | -- The text table records every change a client makes over time. Recording the 25 | -- time allows a timelapse to be generated. An important thing to know is that 26 | -- this table has a composite primary key of t, x and y rather than an auto 27 | -- incremented key. 28 | create table text 29 | ( 30 | t integer not null, 31 | x integer not null, 32 | y integer not null, 33 | c integer, 34 | client integer not null 35 | constraint text_client_id_fk 36 | references client, 37 | constraint text_pk 38 | primary key (t, x, y) 39 | ); 40 | 41 | -- There is an additional index added here which allows faster selects based on 42 | -- the latest text entered. 43 | create index text_t_index 44 | on text (t desc); 45 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Infinite Text 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /script.js: -------------------------------------------------------------------------------- 1 | const socketUrl = `ws://${location.hostname}:10500/ws.php`; 2 | const socket = new WebSocket(socketUrl); 3 | 4 | const canvas = document.querySelector("canvas"); 5 | const ctx = canvas.getContext("2d"); 6 | const debounceTimeouts = {}; 7 | const mobileInput = document.getElementById("mobile-input"); 8 | 9 | const cursor = { 10 | x: null, 11 | y: null, 12 | startX: null, 13 | }; 14 | const camera = { 15 | x: 0, 16 | y: 0, 17 | }; 18 | const mouse = { 19 | x: 0, 20 | y: 0, 21 | down: false 22 | }; 23 | const grid = { 24 | width: 16, 25 | height: 24, 26 | }; 27 | const data = { 28 | "0": { 29 | "0": "h", 30 | "1": "e", 31 | "2": "l", 32 | "3": "l", 33 | "4": "o", 34 | }, 35 | "1": { 36 | "0": "w", 37 | "1": "o", 38 | "2": "r", 39 | "3": "l", 40 | "4": "d", 41 | } 42 | }; 43 | 44 | canvas.addEventListener("pointerdown", mouseDown); 45 | canvas.addEventListener("pointerup", mouseUp); 46 | canvas.addEventListener("pointermove", mouseMove); 47 | window.addEventListener("resize", () => debounce(resizeCanvas)); 48 | window.addEventListener("keydown", keyDown); 49 | if(navigator.maxTouchPoints > 0) { 50 | document.body.classList.add("mobile"); 51 | } 52 | 53 | function debounce(callback) { 54 | if(debounceTimeouts[callback.name]) { 55 | return; 56 | } 57 | else { 58 | callback(); 59 | } 60 | 61 | debounceTimeouts[callback.name] = setTimeout(callback, 100); 62 | setTimeout(function() { 63 | delete debounceTimeouts[callback.name]; 64 | }, 100); 65 | } 66 | 67 | function resizeCanvas() { 68 | canvas.width = window.innerWidth; 69 | canvas.height = window.innerHeight; 70 | if(document.body.classList.contains("mobile")) { 71 | mobileInput.focus(); 72 | } 73 | else { 74 | } 75 | ctx.font = "20px monospace"; 76 | } 77 | 78 | function mouseDown(e) { 79 | if(!mouse.down) { 80 | mouse.down = {}; 81 | } 82 | 83 | mouse.down.x = e.clientX; 84 | mouse.down.y = e.clientY; 85 | mouse.x = mouse.down.x; 86 | mouse.y = mouse.down.y; 87 | } 88 | 89 | function mouseUp(e) { 90 | if(Math.abs(mouse.x - mouse.down.x) < grid.width / 2 && Math.abs(mouse.y - mouse.down.y) < grid.height / 2) { 91 | click( 92 | Math.floor( mouse.x / grid.width) + Math.ceil(camera.x / grid.width), 93 | Math.ceil( mouse.y / grid.height) + Math.ceil(camera.y / grid.height) 94 | ); 95 | } 96 | mouse.down = false; 97 | if(document.body.classList.contains("mobile")) { 98 | mobileInput.focus(); 99 | } 100 | } 101 | 102 | function mouseMove(e) { 103 | if(mouse.down) { 104 | console.log(mouse.x - e.clientX); 105 | let dragVector = { 106 | x: mouse.x - e.clientX, 107 | y: mouse.y - e.clientY, 108 | }; 109 | camera.x += dragVector.x; 110 | camera.y += dragVector.y; 111 | } 112 | 113 | mouse.x = e.clientX; 114 | mouse.y = e.clientY; 115 | } 116 | 117 | function keyDown(e) { 118 | if(e.ctrlKey) { 119 | return; 120 | } 121 | 122 | e.preventDefault(); 123 | 124 | if(e.key.length === 1 && e.key.match(/[a-z0-9!"£$€ß¢%^&*()\-=_+\[\]{};:'@#~,<.>/?\\|`¬ ]/i)) { 125 | setChar(cursor.x, cursor.y, e.key[0]); 126 | cursor.x++; 127 | } 128 | else { 129 | if(e.key === "Enter") { 130 | cursor.y++; 131 | cursor.x = cursor.startX; 132 | } 133 | else if(e.key === "Backspace") { 134 | if(cursor.x > cursor.startX) { 135 | cursor.x--; 136 | setChar(cursor.x, cursor.y, null); 137 | } 138 | } 139 | else if(e.key === "ArrowLeft") { 140 | cursor.x --; 141 | } 142 | else if(e.key === "ArrowRight") { 143 | cursor.x ++; 144 | } 145 | else if(e.key === "ArrowUp") { 146 | cursor.y --; 147 | } 148 | else if(e.key === "ArrowDown") { 149 | cursor.y ++; 150 | } 151 | else if(e.key === "Escape") { 152 | cursor.x = cursor.y = null; 153 | } 154 | else { 155 | // console.log(e); 156 | } 157 | } 158 | 159 | constrainView(cursor.x, cursor.y); 160 | send(cursor.x, cursor.y); 161 | } 162 | 163 | function constrainView(x, y) { 164 | x *= grid.width; 165 | y *= grid.height; 166 | 167 | while(x - camera.x < 0) { 168 | camera.x -= grid.width * 10; 169 | } 170 | while(x - camera.x > canvas.width) { 171 | camera.x += grid.width * 10; 172 | } 173 | while(y - camera.y < 0) { 174 | camera.y -= grid.height * 10; 175 | } 176 | while(y - camera.y > canvas.height) { 177 | camera.y += grid.height * 10; 178 | } 179 | } 180 | 181 | function setChar(x, y, c) { 182 | if(data[y] === undefined) { 183 | data[y] = {}; 184 | } 185 | 186 | if(c === null) { 187 | delete data[y][x]; 188 | if(data[y].length === 0) { 189 | // console.log(data[y]); 190 | delete data[y]; 191 | } 192 | } 193 | else { 194 | data[y][x] = c; 195 | } 196 | send(x, y, c); 197 | } 198 | 199 | function click(x, y) { 200 | select(x, y); 201 | } 202 | 203 | function select(x, y) { 204 | cursor.x = x; 205 | cursor.y = y; 206 | cursor.startX = x; 207 | send(x, y); 208 | } 209 | 210 | function loop(dt) { 211 | update(dt); 212 | draw(dt); 213 | requestAnimationFrame(loop); 214 | } 215 | 216 | function update(dt) { 217 | 218 | } 219 | 220 | function draw() { 221 | ctx.clearRect(0, 0, canvas.width, canvas.height); 222 | drawSelection(); 223 | drawData(); 224 | drawMouse(); 225 | } 226 | 227 | function drawData() { 228 | const border = -20; 229 | 230 | for(let y in data) { 231 | for(let x in data[y]) { 232 | let screenX = Math.floor(((x * grid.width) - camera.x) / grid.width) * grid.width; 233 | let screenY = -4 + Math.floor(((y * grid.height) - camera.y) / grid.height) * grid.height; 234 | 235 | if(screenX < border || screenX > canvas.width - border || screenY < border || screenY > canvas.height - border) { 236 | continue; 237 | } 238 | 239 | if(data[y][x] === null) { 240 | continue; 241 | } 242 | 243 | ctx.fillStyle = "black"; 244 | if(cursor.x === parseInt(x) && cursor.y === parseInt(y)) { 245 | ctx.fillStyle = "white"; 246 | } 247 | ctx.fillText(data[y][x], screenX, screenY); 248 | } 249 | } 250 | } 251 | 252 | function drawMouse() { 253 | let cellX = (Math.floor(mouse.x / grid.width) * grid.width); 254 | let cellY = (Math.floor(mouse.y / grid.height) * grid.height); 255 | ctx.strokeRect(cellX, cellY, grid.width, grid.height); 256 | } 257 | 258 | function drawSelection() { 259 | if(cursor.x === null || cursor.y === null) { 260 | return; 261 | } 262 | 263 | let screenX = (cursor.x * grid.width) - (Math.ceil(camera.x / grid.width) * grid.width); 264 | let screenY = (cursor.y * grid.height) - ((Math.ceil(camera.y / grid.height) + 1) * grid.height); 265 | 266 | ctx.fillStyle = "black"; 267 | ctx.fillRect(screenX, screenY, grid.width, grid.height); 268 | } 269 | 270 | function send(x, y, c) { 271 | let obj = { 272 | x: parseInt(x), 273 | y: parseInt(y), 274 | }; 275 | if(c !== undefined) { 276 | obj.c = c; 277 | } 278 | socket.send(JSON.stringify(obj)); 279 | } 280 | 281 | resizeCanvas(); 282 | camera.x -= Math.floor(canvas.width / 2); 283 | camera.y -= Math.floor(canvas.height / 2); 284 | 285 | socket.onmessage = function(e) { 286 | let obj = JSON.parse(e.data); 287 | for(let i in obj.data) { 288 | if(!obj.data.hasOwnProperty(i)) { 289 | continue; 290 | } 291 | 292 | let y = i.toString(); 293 | 294 | for(let j in obj.data[i]) { 295 | let x = j.toString(); 296 | 297 | if(!data[y]) { 298 | data[y] = {}; 299 | } 300 | data[y][x] = obj.data[i][j]; 301 | } 302 | } 303 | }; 304 | 305 | loop(0); 306 | -------------------------------------------------------------------------------- /server.php: -------------------------------------------------------------------------------- 1 | */ 14 | private array $clientSocketArray; 15 | /** @var float Number of seconds since the server was started */ 16 | private float $totalTime; 17 | private int $timestamp; 18 | 19 | public function __construct( 20 | private string $bindAddress = "0.0.0.0", 21 | private int $port = 10500, 22 | private float $frameDelay = 0.1 23 | ) { 24 | $this->socket = socket_create( 25 | AF_INET, 26 | SOCK_STREAM, 27 | SOL_TCP 28 | ); 29 | 30 | socket_set_option( 31 | $this->socket, 32 | SOL_SOCKET, 33 | SO_REUSEADDR, 34 | 1 35 | ); 36 | socket_bind($this->socket, $this->bindAddress, $this->port); 37 | socket_listen($this->socket); 38 | 39 | $this->clientSocketArray = [$this->socket]; 40 | $this->totalTime = 0; 41 | } 42 | 43 | /** 44 | * This function will never return. It acts as an infinite loop that 45 | * constantly calls the tick function, pausing for 100ms to allow the 46 | * server to catch its breath. Data sent within this pause will still 47 | * be received, as the socket will buffer incoming messages. 48 | * The two parameters allow a callback to be executed whenever there's 49 | * a new connection or new data available. 50 | */ 51 | public function loop( 52 | ?callable $onConnect = null, 53 | ?callable $onData = null, 54 | ?callable $getData = null, 55 | ):void { 56 | // TODO: Add a mechanism for stopping the loop gracefully. 57 | $lastTime = null; 58 | while(true) { 59 | $deltaTime = is_null($lastTime) 60 | ? 0 61 | : microtime(true) - $lastTime; 62 | $this->totalTime += $deltaTime; 63 | $lastTime = microtime(true); 64 | $this->tick($onConnect, $onData, $getData); 65 | usleep($this->frameDelay * 1_000_000); 66 | } 67 | } 68 | 69 | public function send($client, object|string $msg):void { 70 | $msg = $this->mask($msg); 71 | $length = strlen($msg); 72 | @socket_write($client, $msg, $length); 73 | } 74 | 75 | /** 76 | * This is where the main work is done. Any incoming data will be 77 | * processed here - there may be many messages to process, there may be 78 | * no data to process. 79 | */ 80 | private function tick( 81 | ?callable $onConnect, 82 | ?callable $onData, 83 | ?callable $getData, 84 | ):void { 85 | // First make a copy of the client list, so we can manipulate it without losing 86 | // any references to connected clients. 87 | $readClientArray = $this->clientSocketArray; 88 | $writeClientArray = $exceptClientArray = null; 89 | // Check to see if there is any new data on any of the read clients. 90 | socket_select( 91 | $readClientArray, 92 | $writeClientArray, 93 | $exceptClientArray, 94 | 0 95 | ); 96 | 97 | if(in_array($this->socket, $readClientArray)) { 98 | // If the server's socket is in the read client array, it represents a new 99 | // client connecting, which needs to be greeted with a handshake. 100 | echo "New socket incoming..."; 101 | // Accept the new client and add it to the client list for the next tick. 102 | $newSocket = socket_accept($this->socket); 103 | socket_getpeername($newSocket, $address, $port); 104 | array_push($this->clientSocketArray, $newSocket); 105 | 106 | // At the moment, this connection is a plain HTTP connection from a web browser. 107 | // The handshake is the procedure that upgrades the HTTP connection to a 108 | // WebSocket connection. 109 | $headers = socket_read($newSocket, 1024); 110 | preg_match( 111 | "/^Host: (?P[^:]+)/mi", 112 | $headers, 113 | $matches 114 | ); 115 | $this->doHandshake( 116 | $headers, 117 | $newSocket, 118 | $matches["HOST"], 119 | $this->port 120 | ); 121 | 122 | if($onConnect) { 123 | call_user_func($onConnect, $newSocket); 124 | } 125 | 126 | $newSocketIndex = array_search($this->socket, $readClientArray); 127 | unset($readClientArray[$newSocketIndex]); 128 | echo "... $address:$port connected!", PHP_EOL; 129 | } 130 | 131 | foreach($readClientArray as $client) { 132 | socket_getpeername($client, $address, $port); 133 | 134 | while(socket_recv($client, $socketData, 1024, 0) >= 1) { 135 | foreach($this->unmask($socketData) as $socketMessage) { 136 | echo "$address:$port <<< $socketMessage >>>", PHP_EOL; 137 | if($onData) { 138 | call_user_func($onData, $client, $socketMessage); 139 | } 140 | } 141 | break 2; 142 | } 143 | 144 | $socketData = @socket_read($client, 1024, PHP_NORMAL_READ); 145 | if($socketData === false) { 146 | $newSocketIndex = array_search($client, $this->clientSocketArray); 147 | unset($this->clientSocketArray[$newSocketIndex]); 148 | } 149 | } 150 | 151 | $data = null; 152 | if($this->clientSocketArray && $getData) { 153 | $data = call_user_func( 154 | $getData, 155 | $this->timestamp ?? null 156 | ); 157 | } 158 | 159 | $this->timestamp = (int)(microtime(true) * 100); 160 | 161 | if($data) { 162 | foreach($this->clientSocketArray as $client) { 163 | $this->send($client, (object)[ 164 | "type" => "update", 165 | "data" => $data, 166 | ]); 167 | } 168 | } 169 | } 170 | 171 | private function mask(string|object $socketData):string { 172 | if(is_object($socketData)) { 173 | $socketData = json_encode($socketData); 174 | } 175 | 176 | $b1 = 0x80 | (0x1 & 0x0f); 177 | $length = strlen($socketData); 178 | 179 | if($length <= 125) { 180 | $header = pack("CC", $b1, $length); 181 | } 182 | elseif($length < 65536) { 183 | $header = pack("CCn", $b1, 126, $length); 184 | } 185 | else { 186 | $header = pack("CCN", $b1, 127, $length); 187 | } 188 | 189 | return $header . $socketData; 190 | } 191 | 192 | /** @return array */ 193 | private function unmask(string $maskedPayload):array { 194 | $length = ord($maskedPayload[1]) & 127; 195 | $maskLength = 4; 196 | 197 | if($length == 126) { 198 | $maskOffset = 4; 199 | } 200 | elseif($length == 127) { 201 | $maskOffset = 10; 202 | } 203 | else { 204 | $maskOffset = 2; 205 | } 206 | 207 | $masks = substr($maskedPayload, $maskOffset, $maskLength); 208 | $data = substr($maskedPayload, $maskOffset + $maskLength, $length); 209 | $overflow = substr($maskedPayload, $maskOffset + $maskLength + $length); 210 | 211 | $unmaskedArray = [""]; 212 | 213 | for ($i = 0; $i < $length; $i++) { 214 | $unmaskedArray[0] .= $data[$i] ^ $masks[$i%4]; 215 | } 216 | 217 | if($overflow) { 218 | array_push( 219 | $unmaskedArray, 220 | ...$this->unmask($overflow) 221 | ); 222 | } 223 | 224 | return $unmaskedArray; 225 | } 226 | 227 | private function doHandshake( 228 | string $rawHeaders, 229 | Socket $client, 230 | string $address, 231 | int $port 232 | ):void { 233 | $headerArray = []; 234 | $lines = preg_split("/\r\n/", $rawHeaders); 235 | foreach($lines as $line) { 236 | $line = rtrim($line); 237 | if(preg_match('/\A(?P\S+): (?P.*)\z/', $line, $matches)) { 238 | $headerArray[$matches["NAME"]] = $matches["VALUE"]; 239 | } 240 | } 241 | 242 | $secKey = $headerArray["Sec-WebSocket-Key"]; 243 | $secAccept = base64_encode( 244 | pack( 245 | "H*", 246 | sha1($secKey . self::HANDSHAKE) 247 | ) 248 | ); 249 | $buffer = "HTTP/1.1 101 Web Socket Protocol Handshake\r\n" . 250 | "Upgrade: websocket\r\n" . 251 | "Connection: Upgrade\r\n" . 252 | "WebSocket-Origin: $address\r\n" . 253 | "WebSocket-Location: ws://$address:$port/ws.php\r\n". 254 | "Sec-WebSocket-Accept:$secAccept\r\n\r\n"; 255 | 256 | socket_write( 257 | $client, 258 | $buffer, 259 | strlen($buffer) 260 | ); 261 | } 262 | } 263 | -------------------------------------------------------------------------------- /state.php: -------------------------------------------------------------------------------- 1 | A cache of clients, so we do not need to 5 | * look up the ID of clients that have recently connected. The array key 6 | * is the IP:port of the client, the array value is the database ID. 7 | */ 8 | private array $clientIdCache; 9 | 10 | public function __construct( 11 | private PDO $db, 12 | private Canvas $canvas, 13 | ) { 14 | $this->clientIdCache = []; 15 | $this->getData(); 16 | } 17 | 18 | public function clientConnection( 19 | Socket $socket, 20 | Canvas $canvas, 21 | callable $sendFunction, 22 | ):void { 23 | socket_getpeername($socket, $address, $port); 24 | $newId = $this->query("new-client", [ 25 | "ip" => $address, 26 | "port" => $port, 27 | "timestamp" => (int)(microtime(true) * 100), 28 | ]); 29 | $this->clientIdCache["$address:$port"] = $newId; 30 | call_user_func($sendFunction, $socket, (object)[ 31 | "type" => "update", 32 | "data" => $canvas->getData(), 33 | ]); 34 | } 35 | 36 | public function getData(?int $timestamp = null):array { 37 | $params = ["timestamp" => $timestamp]; 38 | foreach($this->query("get-state", $params) as $row) { 39 | $this->canvas->setData( 40 | $row["x"], 41 | $row["y"], 42 | $row["c"] 43 | ); 44 | } 45 | 46 | return $this->canvas->getData((bool)$timestamp); 47 | } 48 | 49 | public function clientData(Socket $socket, string $data):void { 50 | $obj = json_decode($data, true); 51 | socket_getpeername($socket, $address, $port); 52 | $obj["id"] = $this->clientIdCache["$address:$port"]; 53 | $obj["timestamp"] = (int)(microtime(true) * 100); 54 | 55 | if(array_key_exists("c", $obj)) { 56 | $this->query("set-text", $obj); 57 | } 58 | else { 59 | $this->query("update-cursor", $obj); 60 | } 61 | } 62 | 63 | /** 64 | * @param array $data 65 | * @return int|array 66 | */ 67 | private function query(string $name, array $data = []):int|array { 68 | $sql = file_get_contents("db-$name.sql"); 69 | $stmt = $this->db->prepare($sql); 70 | $bindings = []; 71 | foreach($data as $key => $value) { 72 | $bindings[":" . $key] = $value; 73 | } 74 | try { 75 | $stmt->execute($bindings); 76 | } 77 | catch(PDOException $exception) { 78 | echo "Error " . $exception->getCode() . " " . $exception->getMessage(), PHP_EOL; 79 | } 80 | if($stmt->columnCount() > 0) { 81 | return $stmt->fetchAll(PDO::FETCH_ASSOC); 82 | } 83 | else { 84 | return $this->db->lastInsertId() ?: $stmt->rowCount(); 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | html, body { 2 | height: 100%; 3 | } 4 | body { 5 | margin: 0; 6 | overflow: hidden; 7 | background: #eee; 8 | } 9 | canvas { 10 | background: white; 11 | touch-action: none; 12 | } 13 | #mobile-input { 14 | transform: translateX(-100%) scale(0); 15 | } 16 | -------------------------------------------------------------------------------- /ws.php: -------------------------------------------------------------------------------- 1 | query("select `name` FROM `sqlite_schema` where `type` = 'table'")->fetchAll())) { 9 | $db->exec(file_get_contents("db.sql")); 10 | echo "Database created.", PHP_EOL; 11 | } 12 | 13 | $ws = new Server("0.0.0.0", 10500); 14 | $sendFunction = fn(Socket $client, object|string $data) 15 | => $ws->send($client, $data); 16 | 17 | $canvas = new Canvas(); 18 | $state = new State($db, $canvas); 19 | 20 | $ws->loop( 21 | onConnect: fn(Socket $socket) 22 | => $state->clientConnection($socket, $canvas, $sendFunction), 23 | onData: fn(Socket $socket, string $data) 24 | => $state->clientData($socket, $data), 25 | getData: fn(?int $timestamp = null) => $state->getData($timestamp), 26 | ); 27 | --------------------------------------------------------------------------------