Skip to content

feat: add StreamResponse and SSE response factories#10395

Open
michalsn wants to merge 8 commits into
codeigniter4:4.8from
michalsn:feat/stream-response
Open

feat: add StreamResponse and SSE response factories#10395
michalsn wants to merge 8 commits into
codeigniter4:4.8from
michalsn:feat/stream-response

Conversation

@michalsn

@michalsn michalsn commented Jul 8, 2026

Copy link
Copy Markdown
Member

Description
This PR adds first-class support for streaming HTTP responses. It introduces StreamResponse for responses generated progressively, such as large exports, proxied streams, or token-style output, and builds SSEResponse on top of it for Server-Sent Events. Streaming responses can be created from the response service with stream() and eventStream().

Checklist:

  • Securely signed commits
  • Component(s) with PHPDoc blocks, only if necessary or adds value (without duplication)
  • Unit testing, with >80% coverage
  • User guide updated
  • Conforms to style guide

@michalsn michalsn added new feature PRs for new features 4.8 PRs that target the `4.8` branch. labels Jul 8, 2026

@memleakd memleakd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for working on this, looks useful! I left a few comments:

Comment thread system/HTTP/StreamResponse.php Outdated
Comment on lines +126 to +128
// Intentionally skip CSP finalize: the body is streamed, not buffered HTML.
$this->sendHeaders();
$this->sendCookies();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small question about this: for SSE, skipping CSP makes sense to me, but StreamResponse is generic and could stream HTML.

Would it make sense to still finalize CSP headers when CSP is enabled/touched, even if nonce placeholder replacement cannot really work for streamed bodies?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point. I updated StreamResponse to finalize CSP headers when enabled/touched, while keeping SSE explicitly opted out.

Comment thread system/HTTP/StreamResponse.php Outdated
}

if (! $this->hasHeader('Content-Encoding')) {
$this->setHeader('Content-Encoding', 'identity');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I might be reading this too strictly, but would it make sense to avoid setting Content-Encoding: identity here?

RFC 9110 section 8.4-5 says identity is reserved for Accept-Encoding and SHOULD NOT be included in Content-Encoding: https://www.rfc-editor.org/rfc/rfc9110.html#section-8.4-5

Maybe it would be better not to set this by default and leave compression/buffering guidance to the docs/server config?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point. I removed the default Content-Encoding: identity header and updated tests/docs.

Comment on lines +6 to +7
// Forward the upstream file chunk by chunk, without buffering it in memory
$source = fopen('https://storage.internal/reports/annual.pdf', 'rb');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny concern with the example: this opens the upstream stream inside the callback, so if fopen() fails the response headers have probably already been sent.

Would it be clearer to open/validate $source before creating the StreamResponse, then only read and close it inside the callback?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed. I unnecessarily focused only on the streaming mechanism - the example is now updated.

Comment on lines +8 to +10
foreach (model('UserModel')->findAll() as $user) {
// write() returns false when the client disconnects
if (! $stream->write("{$user->id},{$user->email}\n")) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe avoiding findAll() here would make the example clearer? Since this section is about streaming, loading all rows first feels a bit misleading.

Also, this looks like CSV, so users may copy it as-is, but direct interpolation would break for commas, quotes, or newlines. Maybe proper CSV escaping or a simpler plain text/NDJSON example would fit better?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I changed the example to batch records and stream NDJSON, which keeps the focus on streaming and avoids CSV escaping/buffering details.

@memleakd memleakd left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the updates. I left two small follow-up comments.

*/
public function eventStream(callable $callback): SSEResponse
{
return new SSEResponse($callback);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this copy the current protocol version to the new response?

This might matter because SSEResponse skips Connection: keep-alive for HTTP/2+, but this creates a fresh response, so it looks like it falls back to 1.1. In an HTTP/2/3 request, this may still emit the HTTP/1.x-only header.


// Any iterable of string chunks works, including generators.
$chunks = static function (): \Generator {
foreach (model('LogModel')->findAll() as $log) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sorry, I forgot to mention this earlier. This example also uses findAll(), so it may load all rows before yielding them.

Maybe a small fixed iterable, or a batched example, would fit the streaming/low-memory point a bit better?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

4.8 PRs that target the `4.8` branch. new feature PRs for new features

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants