Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
84.00% covered (success)
84.00%
63 / 75
50.00% covered (danger)
50.00%
1 / 2
CRAP
0.00% covered (danger)
0.00%
0 / 1
ChatSseController
84.00% covered (success)
84.00%
63 / 75
50.00% covered (danger)
50.00%
1 / 2
10.41
0.00% covered (danger)
0.00%
0 / 1
 __construct
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 stream
83.78% covered (success)
83.78%
62 / 74
0.00% covered (danger)
0.00%
0 / 1
9.35
1<?php
2
3/**
4 * The Chat SSE Controller for Server-Sent Events real-time messaging.
5 *
6 * This Source Code Form is subject to the terms of the Mozilla Public License,
7 * v. 2.0. If a copy of the MPL was not distributed with this file, You can
8 * obtain one at https://mozilla.org/MPL/2.0/.
9 *
10 * @package   phpMyFAQ
11 * @author    Thorsten Rinne <thorsten@phpmyfaq.de>
12 * @copyright 2026 phpMyFAQ Team
13 * @license   https://www.mozilla.org/MPL/2.0/ Mozilla Public License Version 2.0
14 * @link      https://www.phpmyfaq.de
15 * @since     2026-01-19
16 */
17
18declare(strict_types=1);
19
20namespace phpMyFAQ\Controller\Frontend\Api;
21
22use Closure;
23use Exception;
24use phpMyFAQ\Chat;
25use phpMyFAQ\Controller\AbstractController;
26use phpMyFAQ\Filter;
27use Symfony\Component\HttpFoundation\Request;
28use Symfony\Component\HttpFoundation\StreamedResponse;
29use Symfony\Component\Routing\Attribute\Route;
30
31final class ChatSseController extends AbstractController
32{
33    /**
34     * @param ?Closure(): Chat $chatFactory
35     * @param ?Closure(int): void $sleep
36     * @param ?Closure(): int $timeProvider
37     * @param ?Closure(): bool $connectionAborted
38     * @param ?Closure(string): void $headerEmitter
39     * @param ?Closure(): void $bufferCleaner
40     * @param ?Closure(): void $flusher
41     * @param ?Closure(string): void $outputEmitter
42     */
43    /* @mago-expect lint:excessive-parameter-list - the controller dependencies are injected explicitly */
44    public function __construct(
45        private readonly ?Closure $chatFactory = null,
46        private readonly ?Closure $sleep = null,
47        private readonly ?Closure $timeProvider = null,
48        private readonly ?Closure $connectionAborted = null,
49        private readonly ?Closure $headerEmitter = null,
50        private readonly ?Closure $bufferCleaner = null,
51        private readonly ?Closure $flusher = null,
52        private readonly ?Closure $outputEmitter = null,
53        private readonly int $heartbeatInterval = 15,
54        private readonly int $maxRuntime = 30,
55    ) {
56        parent::__construct();
57    }
58
59    /**
60     * SSE endpoint for real-time message delivery.
61     *
62     * @throws Exception
63     */
64    #[Route(path: 'chat/stream', name: 'api.private.chat.stream', methods: ['GET'])]
65    public function stream(Request $request): StreamedResponse
66    {
67        $this->userIsAuthenticated();
68
69        $userId = $this->currentUser->getUserId();
70        $lastIdValue = Filter::filterVar($request->query->get('lastId', 0), FILTER_VALIDATE_INT);
71        $lastId = $lastIdValue ?? 0;
72        $chat = ($this->chatFactory ?? fn(): Chat => new Chat($this->configuration))();
73
74        $this->session->save();
75
76        return new StreamedResponse(
77            function () use ($userId, $lastId, $chat) {
78                if (ob_get_level() > 0) {
79                    ($this->bufferCleaner ?? static function (): void {
80                        ob_end_clean();
81                    })();
82                }
83
84                ($this->headerEmitter ?? static function (string $header): void {
85                    header($header);
86                })('Content-Type: text/event-stream');
87                ($this->headerEmitter ?? static function (string $header): void {
88                    header($header);
89                })('Cache-Control: no-cache');
90                ($this->headerEmitter ?? static function (string $header): void {
91                    header($header);
92                })('Connection: keep-alive');
93                ($this->headerEmitter ?? static function (string $header): void {
94                    header($header);
95                })('X-Accel-Buffering: no');
96
97                $currentLastId = $lastId;
98                $lastHeartbeat = ($this->timeProvider ?? static fn(): int => time())();
99                $startTime = ($this->timeProvider ?? static fn(): int => time())();
100
101                while (true) {
102                    $messages = $chat->getNewMessages($userId, $currentLastId);
103
104                    if ($messages !== []) {
105                        $messageData = $chat->messagesToArray($messages);
106                        ($this->outputEmitter ?? static function (string $chunk): void {
107                            echo $chunk;
108                        })('data: ' . (string) json_encode($messageData) . "\n\n");
109
110                        $lastMessage = end($messages);
111                        $currentLastId = $lastMessage->getId();
112                    }
113
114                    if (
115                        (($this->timeProvider ?? static fn(): int => time())() - $lastHeartbeat)
116                        >= $this->heartbeatInterval
117                    ) {
118                        ($this->outputEmitter ?? static function (string $chunk): void {
119                            echo $chunk;
120                        })(": heartbeat\n\n");
121                        $lastHeartbeat = ($this->timeProvider ?? static fn(): int => time())();
122                    }
123
124                    if (ob_get_level() > 0) {
125                        ob_flush();
126                    }
127                    ($this->flusher ?? static function (): void {
128                        flush();
129                    })();
130
131                    if (($this->connectionAborted ?? static fn(): bool => connection_aborted() !== 0)()) {
132                        break;
133                    }
134
135                    if ((($this->timeProvider ?? static fn(): int => time())() - $startTime) >= $this->maxRuntime) {
136                        ($this->outputEmitter ?? static function (string $chunk): void {
137                            echo $chunk;
138                        })("event: reconnect\n");
139                        ($this->outputEmitter ?? static function (string $chunk): void {
140                            echo $chunk;
141                        })("data: {\"lastId\": {$currentLastId}}\n\n");
142                        if (ob_get_level() > 0) {
143                            ob_flush();
144                        }
145                        ($this->flusher ?? static function (): void {
146                            flush();
147                        })();
148                        break;
149                    }
150
151                    ($this->sleep ?? static function (int $seconds): void {
152                        sleep(max(0, $seconds));
153                    })(2);
154                }
155            },
156            200,
157            [
158                'Content-Type' => 'text/event-stream',
159                'Cache-Control' => 'no-cache',
160                'Connection' => 'keep-alive',
161                'X-Accel-Buffering' => 'no',
162            ],
163        );
164    }
165}