• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

codeigniter4 / CodeIgniter4 / 21785103418

07 Feb 2026 06:49PM UTC coverage: 85.412% (-0.01%) from 85.425%
21785103418

Pull #9931

github

web-flow
Merge d7085f40f into 746dfa415
Pull Request #9931: feat: `SSEResponse` class for streaming Server-Side Events

47 of 58 new or added lines in 4 files covered. (81.03%)

1 existing line in 1 file now uncovered.

22232 of 26029 relevant lines covered (85.41%)

205.45 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

79.25
/system/HTTP/SSEResponse.php
1
<?php
2

3
declare(strict_types=1);
4

5
/**
6
 * This file is part of CodeIgniter 4 framework.
7
 *
8
 * (c) CodeIgniter Foundation <admin@codeigniter.com>
9
 *
10
 * For the full copyright and license information, please view
11
 * the LICENSE file that was distributed with this source code.
12
 */
13

14
namespace CodeIgniter\HTTP;
15

16
use Closure;
17
use Config\App;
18
use JsonException;
19

20
/**
21
 * HTTP response for Server-Sent Events (SSE) streaming.
22
 *
23
 * @see \CodeIgniter\HTTP\SSEResponseTest
24
 */
25
class SSEResponse extends Response implements NonBufferedResponseInterface
26
{
27
    /**
28
     * Constructor.
29
     *
30
     * @param Closure(SSEResponse): void $callback
31
     */
32
    public function __construct(private readonly Closure $callback)
33
    {
34
        parent::__construct(config(App::class));
13✔
35
    }
36

37
    /**
38
     * Send an SSE event to the client.
39
     *
40
     * @param array<string, mixed>|string $data  Event data (arrays are JSON-encoded)
41
     * @param string|null                 $event Event type
42
     * @param string|null                 $id    Event ID
43
     */
44
    public function event(array|string $data, ?string $event = null, ?string $id = null): bool
45
    {
46
        if ($this->isConnectionAborted()) {
9✔
NEW
47
            return false;
×
48
        }
49

50
        $output = '';
9✔
51

52
        if ($event !== null) {
9✔
53
            $output .= 'event: ' . $this->sanitizeLine($event) . "\n";
2✔
54
        }
55

56
        if ($id !== null) {
9✔
57
            $output .= 'id: ' . $this->sanitizeLine($id) . "\n";
2✔
58
        }
59

60
        if (is_array($data)) {
9✔
61
            try {
62
                $data = json_encode($data, JSON_THROW_ON_ERROR);
2✔
63
            } catch (JsonException $e) {
1✔
64
                log_message('error', 'SSE JSON encode failed: {message}', ['message' => $e->getMessage()]);
1✔
65

66
                return false;
1✔
67
            }
68
        }
69

70
        $output .= $this->formatMultiline('data', $data);
8✔
71

72
        return $this->write($output);
8✔
73
    }
74

75
    /**
76
     * Send an SSE comment (useful for keep-alive).
77
     */
78
    public function comment(string $text): bool
79
    {
80
        if ($this->isConnectionAborted()) {
2✔
NEW
81
            return false;
×
82
        }
83

84
        return $this->write($this->formatMultiline('', $text));
2✔
85
    }
86

87
    /**
88
     * Set the client reconnection interval.
89
     *
90
     * @param int $milliseconds Retry interval in milliseconds
91
     */
92
    public function retry(int $milliseconds): bool
93
    {
94
        if ($this->isConnectionAborted()) {
1✔
NEW
95
            return false;
×
96
        }
97

98
        return $this->write("retry: {$milliseconds}\n\n");
1✔
99
    }
100

101
    /**
102
     * Check if the client connection has been lost.
103
     */
104
    private function isConnectionAborted(): bool
105
    {
106
        return connection_status() !== CONNECTION_NORMAL || connection_aborted() === 1;
12✔
107
    }
108

109
    /**
110
     * Strip newlines from a single-line SSE field (event, id).
111
     */
112
    private function sanitizeLine(string $value): string
113
    {
114
        return str_replace(["\r\n", "\r", "\n"], '', $value);
3✔
115
    }
116

117
    /**
118
     * Format a value as prefixed SSE lines, normalizing line endings.
119
     *
120
     * Each line becomes "{prefix}: {line}\n", terminated by an extra "\n".
121
     */
122
    private function formatMultiline(string $prefix, string $value): string
123
    {
124
        $value  = str_replace(["\r\n", "\r"], "\n", $value);
10✔
125
        $output = '';
10✔
126

127
        foreach (explode("\n", $value) as $line) {
10✔
128
            $output .= ($prefix !== '' ? "{$prefix}: " : ': ') . $line . "\n";
10✔
129
        }
130

131
        return $output . "\n";
10✔
132
    }
133

134
    /**
135
     * Write raw SSE output and flush.
136
     */
137
    private function write(string $output): bool
138
    {
139
        echo $output;
11✔
140

141
        if (ENVIRONMENT !== 'testing') {
11✔
NEW
142
            if (ob_get_level() > 0) {
×
NEW
143
                ob_flush();
×
144
            }
145

NEW
146
            flush();
×
147
        }
148

149
        return true;
11✔
150
    }
151

152
    /**
153
     * {@inheritDoc}
154
     *
155
     * @return $this
156
     */
157
    public function send()
158
    {
159
        // Turn off output buffering completely, even if php.ini output_buffering is not off
160
        if (ENVIRONMENT !== 'testing') {
1✔
NEW
161
            set_time_limit(0);
×
NEW
162
            ini_set('zlib.output_compression', 'Off');
×
163

NEW
164
            while (ob_get_level() > 0) {
×
NEW
165
                ob_end_clean();
×
166
            }
167
        }
168

169
        // Close session if active to prevent blocking other requests
170
        if (session_status() === PHP_SESSION_ACTIVE) {
1✔
NEW
171
            session_write_close();
×
172
        }
173

174
        $this->setContentType('text/event-stream', 'UTF-8');
1✔
175
        $this->removeHeader('Cache-Control');
1✔
176
        $this->setHeader('Cache-Control', 'no-cache');
1✔
177
        $this->setHeader('Content-Encoding', 'identity');
1✔
178
        $this->setHeader('X-Accel-Buffering', 'no');
1✔
179

180
        // Connection: keep-alive is only valid for HTTP/1.x
181
        if (version_compare($this->getProtocolVersion(), '2.0', '<')) {
1✔
182
            $this->setHeader('Connection', 'keep-alive');
1✔
183
        }
184

185
        // Intentionally skip CSP finalize: no HTML/JS execution in SSE streams.
186
        $this->sendHeaders();
1✔
187
        $this->sendCookies();
1✔
188

189
        ($this->callback)($this);
1✔
190

191
        return $this;
1✔
192
    }
193

194
    /**
195
     * {@inheritDoc}
196
     *
197
     * No-op — body is streamed via the callback, not stored.
198
     *
199
     * @return $this
200
     */
201
    public function sendBody()
202
    {
203
        return $this;
1✔
204
    }
205
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc