Add Server-Sent Events

git-svn-id: https://svn.fournier38.fr/svn/ProgSVN/trunk@6062 bf3deb0d-5f1a-0410-827f-c0cc1f45334c
This commit is contained in:
2020-08-28 12:44:03 +00:00
parent 0ff91d1353
commit 6e846514c6
2 changed files with 384 additions and 0 deletions

98
Tests/sseTest.php Normal file
View File

@@ -0,0 +1,98 @@
<?php
/** DomFramework
* @package domframework
* @author Dominique Fournier <dominique@fournier38.fr>
*/
/** 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 ();
}
}

286
sse.php Normal file
View File

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