* @license BSD */ namespace Domframework; /** 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(); /** The handlers parameters */ private $handlersParams = 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 in DataOnly. Must be callable method * @param callable|null $handler The handler * If callable is null, remove the handler for this event * @param mixed... $params The optional needed parameters */ final public function setHandlerDataonly($handler, $params = null) { if (! is_callable($handler) && $handler !== null) { throw new \Exception(dgettext( "domframework", "SSE : can not use handler : not callable" ), 500); } $this->handlers["data"] = $handler; $args = func_get_args(); array_shift($args); $this->handlersParams["data"] = $args; return $this; } /** The optional handler to use in Events. Must be array of callable methods * @param array $handlers The handlers method, array[event=>callable] * @param array|mixed $params The parameters of the handlers * array[event=>callable] * If callable is null, remove the handler for this event * @param array|null $params The optional needed parameters * array(event=>array(params)) * if event=>null is set, remove the parameters for the event */ final public function setHandlersEvent($handlers, $params = null) { if (! is_array($handlers)) { throw new \Exception(dgettext( "domframework", "SSE : can not use handler : not array" ), 500); } if (! is_array($params) && $params !== null) { throw new \Exception(dgettext( "domframework", "SSE : can not use handler params : not array" ), 500); } foreach ($handlers as $event => $handler) { if ($handler === null && key_exists($event, $this->handlers)) { unset($this->handlers[$event]); unset($this->handlersParams[$event]); continue; } if (! is_callable($handler)) { throw new \Exception(dgettext( "domframework", "SSE : can not use handler : not callable" ), 500); } $this->handlers[$event] = $handler; } if (is_array($params)) { foreach ($params as $event => $param) { if (! key_exists($event, $this->handlers)) { throw new \Exception(dgettext( "domframework", "SSE : can not use parameter '$event' : no associated handler" ), 500); } if ($param === null) { unset($this->handlersParams[$event]); } else { $this->handlersParams[$event] = $param; } } } 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) { $lines = $this->backendGet($event); if ($lines === false) { continue; } $lines = rtrim($lines); // As data may contains multiple lines, split them foreach (explode("\n", $lines) as $line) { if ( key_exists($event, $this->handlers) && is_callable($this->handlers[$event]) ) { $array = array($line); if (key_exists($event, $this->handlersParams)) { $array = array_merge($array, $this->handlersParams[$event]); } $line = call_user_func_array($this->handlers[$event], $array); } if ($line === 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($line))) . "\n\n"; } } if (defined("PHPUNIT")) { $testloop--; pcntl_signal_dispatch(); } else { @ob_end_flush(); @flush(); } // 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 boolean 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; } }