listen(function () use ($sse) { * $sse->emitEvent([ * 'event' => 'ping', * 'message' => 'hello', * 'time' => current_time('mysql'), * ]); * * // Optionally close stream under a condition * if ( some_condition() ) { * $sse->emitEvent([ * 'event' => 'end', * 'message' => 'done', * ], true); * } * }); * ``` */ class EventStreamServer { /** * Whether the connection should remain open. * * @var bool */ private bool $connected = true; /** * @var int */ private int $id = 0; private bool $is_reconnect = false; /** * Prepares the HTTP headers and environment for a Server-Sent Events stream. * * Disables buffering and compression, sets correct headers, and ends any existing output buffers. */ protected function setupHeaders(): void { // phpcs:disable $previous = error_reporting( error_reporting() ^ E_WARNING ); // Disable warnings temporarily // Required headers for SSE header( 'Content-Type: text/event-stream' ); header( 'Cache-Control: no-cache' ); header( 'Connection: keep-alive' ); // Prevent Apache buffering if ( function_exists( 'apache_setenv' ) ) { @apache_setenv( 'no-gzip', 1 ); } // Disable PHP buffering @ini_set( 'output_buffering', 'off' ); @ini_set( 'zlib.output_compression', 0 ); @ini_set( 'implicit_flush', 1 ); // NGINX-specific buffering control if ( ! empty( $_SERVER['SERVER_SOFTWARE'] ) && stripos( $_SERVER['SERVER_SOFTWARE'], 'nginx' ) !== false ) { header( 'X-Accel-Buffering: no' ); header( 'Content-Encoding: none' ); } $this->id = intval( wp_unslash( $_SERVER['HTTP_LAST_EVENT_ID'] ?? 0 ) ); $this->is_reconnect = isset( $_SERVER['HTTP_LAST_EVENT_ID'] ); // Restore error reporting previous state. error_reporting( $previous ); // Prevent script timeout set_time_limit( 0 ); // Clean existing output buffers while ( ob_get_level() != 0 ) { ob_end_flush(); } ob_implicit_flush( 1 ); flush(); // phpcs:enable } /** * Starts the event stream and repeatedly invokes the provided callback. * * The callback should use emitEvent() to send data to the client. * * @param callable $callback The function that emits events. Called continuously while connected. */ public function listen( callable $callback ): void { $this->setupHeaders(); // Initial padding to prevent browser-side buffering (especially in IE) echo ':' . str_repeat( ' ', 2048 ) . "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped flush(); $start = time(); echo 'retry: ' . 1000 . "\n"; while ( $this->connected ) { $upTime = ( time() - $start ); if ( $upTime % 300 === 0 ) { // No updates needed, send a comment to keep the connection alive. // From https://developer.mozilla.org/en-US/docs/Server-sent_events/Using_server-sent_events echo ': ' . sha1( wp_rand() ) . "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped } try { call_user_func( $callback ); } catch ( \Exception $e ) { Helper::log_error( $e ); $this->emitEvent( [ 'event' => 'error', 'message' => $e->getMessage(), 'code' => $e->getCode(), ] ); } @ob_flush(); flush(); // if the connection has been closed by the client we better exit the loop if ( connection_aborted() || $upTime > 600 ) { break; } // Prevent tight infinite loop usleep( 100000 ); // 0.1 second } } public function terminate(): void { $this->connected = false; sleep( 1 ); // Delay to allow client to receive final message exit; } /** * Emits a Server-Sent Event to the client. * * @param array $data { * The data to send in the event. * * @type string $event Optional. The event name. Defaults to 'message'. * @type string $type Optional. message type for js. * @type mixed $n Additional fields included as JSON in the data payload. * } * * @param bool $terminate Optional. Whether to terminate the connection after sending. Default false. * * @return void */ public function emitEvent( array $data = [], bool $terminate = false ): void { $event = esc_attr( $data['event'] ?? 'message' ); unset( $data['event'] ); echo "id: {$this->getNewId()}\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped -- integer escaping! echo "event: {$event}\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped echo 'data: ' . wp_json_encode( $data ) . "\n\n"; // Browser padding for IE echo ':' . str_repeat( ' ', 2048 ) . "\n\n"; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped @ob_flush(); flush(); if ( $terminate ) { $this->terminate(); } } public function getNewId(): int { return $this->id ++; } } // End of file event-stream-server.php.