Skip to content

Commit 4721eee

Browse files
committed
refine streaming response headers and docs
1 parent 7004211 commit 4721eee

7 files changed

Lines changed: 117 additions & 26 deletions

File tree

system/HTTP/ResponseTrait.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -394,7 +394,7 @@ public function send()
394394
* need it avoids the cost of constructing a 1000+ line service on every
395395
* request.
396396
*/
397-
private function shouldFinalizeCsp(): bool
397+
protected function shouldFinalizeCsp(): bool
398398
{
399399
// Developer already touched CSP through getCSP(); respect it.
400400
if ($this->CSP !== null) {

system/HTTP/SSEResponse.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -124,12 +124,22 @@ protected function prepareStreamHeaders(): void
124124
$this->setContentType('text/event-stream', 'UTF-8');
125125
$this->removeHeader('Cache-Control');
126126
$this->setHeader('Cache-Control', 'no-cache');
127-
$this->setHeader('Content-Encoding', 'identity');
128127
$this->setHeader('X-Accel-Buffering', 'no');
129128

130129
// Connection: keep-alive is only valid for HTTP/1.x
131130
if (version_compare($this->getProtocolVersion(), '2.0', '<')) {
132131
$this->setHeader('Connection', 'keep-alive');
133132
}
134133
}
134+
135+
/**
136+
* {@inheritDoc}
137+
*
138+
* CSP is not finalized for SSE responses, as Content Security
139+
* Policy does not apply to event streams.
140+
*/
141+
protected function shouldFinalizeCsp(): bool
142+
{
143+
return false;
144+
}
135145
}

system/HTTP/StreamResponse.php

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,13 @@ public function send()
123123

124124
$this->prepareStreamHeaders();
125125

126-
// Intentionally skip CSP finalize: the body is streamed, not buffered HTML.
126+
// Give CSP a chance to build its headers. Nonce placeholders cannot be
127+
// replaced in a streamed body; call getCSP()->getScriptNonce() or
128+
// getStyleNonce() before returning the response instead.
129+
if ($this->shouldFinalizeCsp()) {
130+
$this->getCSP()->finalize($this);
131+
}
132+
127133
$this->sendHeaders();
128134
$this->sendCookies();
129135

@@ -141,10 +147,6 @@ protected function prepareStreamHeaders(): void
141147
if (! $this->hasHeader('X-Accel-Buffering')) {
142148
$this->setHeader('X-Accel-Buffering', 'no');
143149
}
144-
145-
if (! $this->hasHeader('Content-Encoding')) {
146-
$this->setHeader('Content-Encoding', 'identity');
147-
}
148150
}
149151

150152
/**

tests/system/HTTP/StreamResponseSendTest.php

Lines changed: 48 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
namespace CodeIgniter\HTTP;
1515

1616
use CodeIgniter\Test\CIUnitTestCase;
17+
use Config\App;
1718
use Generator;
1819
use PHPUnit\Framework\Attributes\Group;
1920
use PHPUnit\Framework\Attributes\PreserveGlobalState;
@@ -44,7 +45,7 @@ public function testSendEmitsHeadersCookiesAndStream(): void
4445

4546
$this->assertSame('Hello World', $output);
4647
$this->assertHeaderEmitted('X-Accel-Buffering: no');
47-
$this->assertHeaderEmitted('Content-Encoding: identity');
48+
$this->assertHeaderNotEmitted('Content-Encoding:');
4849
$this->assertHeaderEmitted('Set-Cookie: foo=bar;');
4950
}
5051

@@ -121,4 +122,50 @@ public function testSendEmitsCustomStatusCode(): void
121122

122123
$this->assertSame(202, http_response_code());
123124
}
125+
126+
/**
127+
* This test does not test that CSP is handled properly -
128+
* it makes sure that sending gives CSP a chance to do its thing.
129+
*/
130+
#[PreserveGlobalState(false)]
131+
#[RunInSeparateProcess]
132+
#[WithoutErrorHandler]
133+
public function testSendEmitsCspHeaderWhenEnabled(): void
134+
{
135+
$this->resetFactories();
136+
$this->resetServices();
137+
138+
config(App::class)->CSPEnabled = true;
139+
140+
$response = new StreamResponse(static function (): void {
141+
});
142+
$response->pretend(false);
143+
144+
ob_start();
145+
$response->send();
146+
ob_end_clean();
147+
148+
$this->assertHeaderEmitted('Content-Security-Policy:');
149+
}
150+
151+
#[PreserveGlobalState(false)]
152+
#[RunInSeparateProcess]
153+
#[WithoutErrorHandler]
154+
public function testSendSkipsCspHeaderForSSE(): void
155+
{
156+
$this->resetFactories();
157+
$this->resetServices();
158+
159+
config(App::class)->CSPEnabled = true;
160+
161+
$response = new SSEResponse(static function (): void {
162+
});
163+
$response->pretend(false);
164+
165+
ob_start();
166+
$response->send();
167+
ob_end_clean();
168+
169+
$this->assertHeaderNotEmitted('Content-Security-Policy:');
170+
}
124171
}

user_guide_src/source/outgoing/response.rst

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -252,7 +252,12 @@ The callback receives the ``StreamResponse`` instance. ``StreamResponse::write()
252252
returns ``false`` once the client has disconnected, so you can stop producing
253253
output early. By default, every ``write()`` flushes output to the client
254254
immediately. When writing many small chunks, pass ``false`` as the second
255-
argument and call ``StreamResponse::flush()`` at intervals instead.
255+
argument and call ``StreamResponse::flush()`` at intervals instead - the
256+
example above flushes once per batch.
257+
258+
The callback is not limited to ``write()``: you may also write directly to
259+
``php://output`` (for example, with ``fputcsv()`` for CSV exports), calling
260+
``flush()`` and ``StreamResponse::isClientConnected()`` yourself.
256261

257262
Alternatively, you may pass an iterable of string chunks (such as a generator),
258263
and each chunk is written and flushed in order:
@@ -264,16 +269,25 @@ Headers and Status Code
264269

265270
Set the content type, status code, and any custom headers **before** returning
266271
the response - anything set inside the callback will be too late. Unless you
267-
have already set them, ``StreamResponse`` applies ``X-Accel-Buffering: no`` and
268-
``Content-Encoding: identity`` to discourage intermediaries from buffering or
269-
compressing the stream.
272+
have already set it, ``StreamResponse`` applies ``X-Accel-Buffering: no`` to
273+
discourage intermediaries from buffering the stream. PHP's zlib output
274+
compression is turned off automatically, but if your web server or CDN
275+
compresses responses (e.g., nginx ``gzip`` or Apache ``mod_deflate``), configure
276+
it to skip your streaming endpoints - compression can delay chunk delivery.
270277

271278
The response is streamed: output buffering is disabled, the PHP time limit is
272279
removed, and the session is closed to avoid blocking other requests. After
273280
filters still run and may set headers, but they must not rely on the response
274281
body. View rendering and decorators are not applied - stream your output in
275282
the callback.
276283

284+
If :doc:`Content Security Policy </outgoing/csp>` is enabled, the
285+
``Content-Security-Policy`` header is sent as usual. However, nonce
286+
placeholders cannot be replaced in a streamed body. When streaming HTML that
287+
needs a nonce, call ``$this->response->getCSP()->getScriptNonce()`` (or
288+
``getStyleNonce()``) **before** returning the response and embed the returned
289+
value in your output yourself.
290+
277291
``StreamResponse`` does not set a ``Content-Length`` header by default, since
278292
the body length is typically unknown in advance. Without it, clients cannot
279293
display download progress or resume interrupted transfers. If you do know the
@@ -369,9 +383,9 @@ Production Considerations
369383
Some server stacks and CDNs buffer or compress responses (e.g., Apache with
370384
``mod_deflate``), which can break real-time SSE delivery.
371385
``SSEResponse`` disables PHP output buffering, turns off zlib output
372-
compression, and sets ``Content-Encoding: identity`` and ``X-Accel-Buffering: no``.
373-
However, intermediaries may still buffer or compress, so configure your web server
374-
or CDN to disable buffering/compression for SSE endpoints.
386+
compression, and sets ``X-Accel-Buffering: no``. However, intermediaries may
387+
still buffer or compress, so configure your web server or CDN to disable
388+
buffering/compression for SSE endpoints.
375389

376390
Example: Product-Oriented Use Case
377391
----------------------------------

user_guide_src/source/outgoing/response/039.php

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,24 @@
22

33
use CodeIgniter\HTTP\StreamResponse;
44

5-
return $this->response->stream(static function (StreamResponse $stream) {
6-
$stream->write("id,email\n");
5+
return $this->response
6+
->stream(static function (StreamResponse $stream) {
7+
$userModel = model('UserModel');
8+
$offset = 0;
79

8-
foreach (model('UserModel')->findAll() as $user) {
9-
// write() returns false when the client disconnects
10-
if (! $stream->write("{$user->id},{$user->email}\n")) {
11-
break;
10+
// Fetch rows in batches to keep memory usage low
11+
while ($users = $userModel->findAll(500, $offset)) {
12+
foreach ($users as $user) {
13+
// write() returns false once the client has disconnected
14+
if (! $stream->write(json_encode($user) . "\n", false)) {
15+
return;
16+
}
17+
}
18+
19+
// Push the whole batch to the client at once
20+
$stream->flush();
21+
22+
$offset += 500;
1223
}
13-
}
14-
});
24+
})
25+
->setContentType('application/x-ndjson');

user_guide_src/source/outgoing/response/041.php

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,18 @@
11
<?php
22

3+
use CodeIgniter\Exceptions\PageNotFoundException;
34
use CodeIgniter\HTTP\StreamResponse;
45

5-
$stream = $this->response->stream(static function (StreamResponse $stream) {
6-
// Forward the upstream file chunk by chunk, without buffering it in memory
7-
$source = fopen('https://storage.internal/reports/annual.pdf', 'rb');
6+
// Open and validate the upstream source before creating the response,
7+
// so a failure can still produce a proper error page
8+
$source = @fopen('https://storage.internal/reports/annual.pdf', 'rb');
9+
10+
if ($source === false) {
11+
throw PageNotFoundException::forPageNotFound();
12+
}
813

14+
$stream = $this->response->stream(static function (StreamResponse $stream) use ($source) {
15+
// Forward the upstream file chunk by chunk, without buffering it in memory
916
try {
1017
while (! feof($source)) {
1118
$chunk = fread($source, 1_048_576);

0 commit comments

Comments
 (0)