diff --git a/Tests/sseTest.php b/Tests/sseTest.php new file mode 100644 index 0000000..8bc54c6 --- /dev/null +++ b/Tests/sseTest.php @@ -0,0 +1,98 @@ + + */ + +/** Test the domframework Server-Sent Events part */ +class sseTest extends PHPUnit_Framework_TestCase +{ + public function test_loop_NOTDEFINED () + { + $this->expectException ("Exception"); + $sse = new sse (); + $res = $sse->loop (); + } + + public function test_loop_JUSTPING () + { + $this->expectOutputString(str_repeat (": ping\n\n", 5)); + $sse = new sse (); + @unlink ("/tmp/dfwTestSSE1"); + $sse->setBackendFiles ("/tmp/dfwTestSSE1") + ->setPingTime(1); + $sse->loop (); + } + + public function test_loop_SKIP_START () + { + $this->expectOutputString(str_repeat (": ping\n\n", 5)); + $sse = new sse (); + @unlink ("/tmp/dfwTestSSE1"); + file_put_contents ("/tmp/dfwTestSSE1", "NOT SEEN"); + $sse->setBackendFiles ("/tmp/dfwTestSSE1") + ->setPingTime(1); + $sse->loop (); + } + + public function test_loop_DATA () + { + $this->expectOutputString(str_repeat (": ping\n\n", 4). + "data: WILL BE SEEN\n\n: ping\n\n"); + @unlink ("/tmp/dfwTestSSE1"); + $sse = new sse (); + pcntl_signal(SIGALRM, function () { + file_put_contents ("/tmp/dfwTestSSE1", "WILL BE SEEN\n"); + }, false); + pcntl_alarm(3); + $sse->setBackendFiles ("/tmp/dfwTestSSE1") + ->setPingTime(1); + $sse->loop (); + } + + public function test_loop_EVENTS () + { + $this->expectOutputString(str_repeat (": ping\n\n", 4). + "event: event1\ndata: WILL BE SEEN 1\n\n". + "event: event2\ndata: WILL BE SEEN 2\n\n". + ": ping\n\n"); + @unlink ("/tmp/dfwTestSSE1"); + @unlink ("/tmp/dfwTestSSE2"); + $sse = new sse (); + pcntl_signal(SIGALRM, function () { + file_put_contents ("/tmp/dfwTestSSE1", "WILL BE SEEN 1\n"); + file_put_contents ("/tmp/dfwTestSSE2", "WILL BE SEEN 2\n"); + }, false); + pcntl_alarm(3); + $sse->setBackendFiles (["event1" => "/tmp/dfwTestSSE1", + "event2" => "/tmp/dfwTestSSE2"]) + ->setPingTime(1); + $sse->loop (); + } + + public function test_loop_Handler () + { + $this->expectOutputString(str_repeat (": ping\n\n", 4). + "event: event1\ndata: will be seen 1\n\n". + "event: event2\ndata: WILL BE SEEN 2\n\n". + ": ping\n\n"); + @unlink ("/tmp/dfwTestSSE1"); + @unlink ("/tmp/dfwTestSSE2"); + $sse = new sse (); + pcntl_signal(SIGALRM, function () { + file_put_contents ("/tmp/dfwTestSSE1", "will be seen 1\n"); + file_put_contents ("/tmp/dfwTestSSE2", "WILL BE SEEN 2\n"); + }, false); + pcntl_alarm(3); + function lower ($val) { + return strtolower ($val); + } + $sse->setBackendFiles (["event1" => "/tmp/dfwTestSSE1", + "event2" => "/tmp/dfwTestSSE2"]) + ->setHandlers (["event1" => "lower"]) + ->setPingTime(1); + $sse->loop (); + } +} + + diff --git a/sse.php b/sse.php new file mode 100644 index 0000000..98eb6dd --- /dev/null +++ b/sse.php @@ -0,0 +1,286 @@ + + */ + +/** This class allow to manage Server-Sent Events + * The browser will be connected to a not ending loop. This loop will send + * ping regularly. If the backend value change, it will be sent to the + * browser. + * The backend will be a file for each event name. Each line of the file will + * be sent when it will be added + * See https://developer.mozilla.org/fr/docs/Web/API/Server-sent_events/ + * The developper can use a handler to read/modify each event before it is + * send to the user. + */ +class sse +{ + ////////////////////////// + //// PROPERTIES //// + ////////////////////////// + // {{{ + /** The backend File to use + */ + private $files; + + /** Set the time between each ping (in seconds). 30 by default + */ + private $pingTime = 30; + + /** The filepointers storage + */ + private $fps = array (); + + /** The handlers storage + */ + private $handlers = array (); + // }}} + + ////////////////////////////// + //// SETTER METHODS //// + ////////////////////////////// + /** The backend Files to use. + * The backend directory used to store the files must exists and be readable + * If array (namedEvent => filePath, "data" => dataFilePath); + * If string dataFilePath + * @param string|array $files The files to use + * @return $this + */ + final public function setBackendFiles ($files) + // {{{ + { + if (! is_array ($files)) + $files = array ("data" => $files); + foreach ($files as $event => $file) + { + if (! file_exists (dirname ($file))) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Can not use the backend file : ". + "directory does not exists : %s"), dirname ($file)), 500); + if (! is_readable (dirname ($file))) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Can not use the backend file : ". + "directory not readable : %s"), dirname ($file)), 500); + if (! ctype_graph ($event)) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Can not create the backend file : ". + "event name contains invalid chars : %s"), $event), 403); + } + $this->files = $files; + return $this; + } + // }}} + + /** The pingTime to use. Must be positive. If null, the ping is disabled + * @param integer|float $pingTime The time in seconds between two keepalive + * pings + */ + final public function setPingTime ($pingTime) + // {{{ + { + if (! is_float ($pingTime) && ! is_integer ($pingTime)) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Can not use requested pingTime : not a float : %s"), + $pingTime), 500); + if ($pingTime < 0) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Can not use requested pingTime : not positive : %s"), + $pingTime), 500); + $this->pingTime = $pingTime; + return $this; + } + // }}} + + /** The optional handler to use. Must be array of callable methods + * @param array|callable $handlers The handler method, array[event=>callable] + * If callable is null, remove the handler for this event + */ + final public function setHandlers ($handlers) + // {{{ + { + if (is_callable ($handlers) || $handlers === null) + $handlers = array ("data" => $handlers); + if (! is_array ($handlers)) + throw new \Exception (dgettext ("domframework", + "SSE : can not use handler : not array"), 500); + foreach ($handlers as $event => $handler) + { + if ($handler === null && key_exists ($event, $this->handlers)) + { + unset ($this->handlers[$event]); + continue; + } + if (! is_callable ($handler)) + throw new \Exception (dgettext ("domframework", + "SSE : can not use handler : not callable"), 500); + $this->handlers[$event] = $handler; + } + return $this; + } + // }}} + + ////////////////////////////// + //// PUBLIC METHODS //// + ////////////////////////////// + /** This method is called by the user's browser. + * It send the "ping" each X second and send the backend content if it + * is updated. + * Never return ! + */ + final public function loop () + // {{{ + { + if ($this->files === null) + throw new \Exception (dgettext ("domframework", + "SSE : Can not use the backend file : file is not defined"), 500); + $this->backendInit(); + // The last ping in float + $lastping = 0; + @header ("Cache-Control: no-cache"); // Disable caching of response + @header ("X-Accel-Buffering: no"); // Disable NGINX Buffering + @header ("Content-Type: text/event-stream"); + // In tests, abort after 100ms * 50 = 5s. In production, never abort as the + // counter is never decreased + $testloop = 50; + while ($testloop) + { + // Each $pingTime, send a comment ":ping" to keepalive the connection + if ($this->pingTime > 0 && $lastping + $this->pingTime < microtime (true)) + { + echo ": ping\n\n"; + $lastping = microtime (true); + } + + // Try to get data from backends + foreach ($this->files as $event => $filePath) + { + $data = $this->backendGet ($event); + if ($data === false) + continue; + if (key_exists ($event, $this->handlers) && + is_callable ($this->handlers[$event])) + $data = call_user_func ($this->handlers[$event], $data); + if ($data === false) + continue; + // If the event is data, do not display "event:data", but data: + // immediately + if ($event !== "data") + { + echo "event: $event\n"; + $event = "data"; + } + echo "$event: ".implode ("\n$event: ", explode ("\n", rtrim ($data))). + "\n\n"; + if (! defined ("PHPUNIT")) + { + @ob_end_flush(); + @flush(); + } + } + if (defined ("PHPUNIT")) + { + $testloop--; + pcntl_signal_dispatch(); + } + // Loop each 100ms if there is no data. + usleep(100000); + } + } + // }}} + + /////////////////////////////// + //// PRIVATE METHODS //// + /////////////////////////////// + /** Initialize the backend : open all the files and go at the end + */ + private function backendInit () + // {{{ + { + if ($this->files === null) + throw new \Exception (dgettext ("domframework", + "SSE : Can not use the backend files : files are not defined"), 500); + foreach ($this->files as $event => $filePath) + { + $this->backendInitEvent ($event, $filePath); + if (key_exists ($event, $this->fps)) + { + fseek ($this->fps[$event], 0, SEEK_END); + stream_set_blocking ($this->fps[$event], false); + } + } + } + // }}} + + /** Get the pending lines from requested backend + * @param string $event The event to get data + * @return null if there is no new line pending + * @return string the data lines stored in the lines + */ + private function backendGet ($event) + // {{{ + { + // If the file was previously opened but doesn't exists anymore. Close it + // and reopen it just after + if (key_exists ($event, $this->fps) && ( + ! file_exists ($this->files[$event]) || + ! is_readable ($this->files[$event]))) + { + @fclose ($this->fps[$event]); + unset ($this->fps[$event]); + } + // If the file was not previously opened, try to open it. Abort if it + // doesn't exists again + if (! key_exists ($event, $this->fps)) + { + if ($this->backendInitEvent ($event, $this->files[$event]) === false) + return false; + } + $fp = $this->fps[$event]; + // If the file is truncated by an external action, must restart at 0 + if (fstat($fp)["size"] < ftell ($fp)) + { + rewind ($fp); + } + $res = false; + while (($line = stream_get_line ($fp, 10000, "\n")) !== false) + { + $res .= "$line\n"; + } + return $res; + } + // }}} + + /** Initialize the backend for one specific file. + * @param string $event The event to start + * @param string $filePath The file path where the event data are stored + * @return false if the file doesn't exists + */ + private function backendInitEvent ($event, $filePath) + // {{{ + { + clearstatcache (true, $filePath); + if (! file_exists (dirname ($filePath))) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Backend directory no more exists : %s"), dirname ($filePath)), + 500); + if (! is_readable (dirname ($filePath))) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Backend directory no more readable : %s"), dirname ($filePath)), + 500); + if (! file_exists ($filePath)) + return false; + if (! is_readable ($filePath)) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Backend event file not readable : %s"), $filePath), + 500); + $fp = fopen ($filePath, "r"); + if ($fp === false) + throw new \Exception (sprintf (dgettext ("domframework", + "SSE : Backend event file not openable : %s"), $filePath), + 500); + $this->fps[$event] = $fp; + return $this; + } + // }}} +}