Files
DomFramework/src/Sse.php

377 lines
13 KiB
PHP

<?php
/** DomFramework
* @package domframework
* @author Dominique Fournier <dominique@fournier38.fr>
* @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;
}
}