diff --git a/src/Http/README.md b/src/Http/README.md index a2219e1..0c6fb7d 100644 --- a/src/Http/README.md +++ b/src/Http/README.md @@ -126,13 +126,23 @@ Other helpers: ```php use Myxa\Support\Facades\Response; +use Myxa\Http\StreamWriterInterface; Response::text('Created', 201); Response::html('

Hello

'); +Response::streaming(function (StreamWriterInterface $stream): void { + $stream->write("event: ping\n"); + $stream->write('data: {"ok":true}' . "\n\n"); +}, 200, [ + 'Content-Type' => 'text/event-stream', + 'X-Accel-Buffering' => 'no', +]); Response::redirect('/login'); Response::noContent(); ``` +`Response::streaming()` is generic. It sends headers and cookies as usual, then runs the callback with a `StreamWriterInterface` so you can write chunks to the client without repeating `ob_flush()` and `flush()` by hand. It does not set a default `Content-Type` or `X-Accel-Buffering` header automatically, because those depend on what you are streaming, such as SSE, NDJSON, CSV, or plain text. + Headers and cookies can be chained onto the response: ```php diff --git a/src/Http/Response.php b/src/Http/Response.php index da35ed7..fc78c7a 100644 --- a/src/Http/Response.php +++ b/src/Http/Response.php @@ -20,6 +20,11 @@ final class Response private string $content = ''; + /** + * @var callable|null + */ + private $streamCallback = null; + /** * @var array */ @@ -78,6 +83,7 @@ public function statusCode(): int */ public function body(string $content): self { + $this->streamCallback = null; $this->content = $content; return $this; @@ -88,6 +94,7 @@ public function body(string $content): self */ public function append(string $content): self { + $this->streamCallback = null; $this->content .= $content; return $this; @@ -101,6 +108,34 @@ public function content(): string return $this->content; } + /** + * Configure the response to stream content when sent. + * + * @param callable(StreamWriterInterface): void $callback + * @param array $headers + */ + public function streaming(callable $callback, int $statusCode = 200, array $headers = []): self + { + $this->status($statusCode); + $this->streamCallback = $callback; + $this->content = ''; + $this->removeHeader('Content-Length'); + + foreach ($headers as $name => $value) { + $this->setHeader($name, (string) $value); + } + + return $this; + } + + /** + * Determine whether the response is configured for streaming output. + */ + public function isStreaming(): bool + { + return is_callable($this->streamCallback); + } + /** * Set or replace a response header value. */ @@ -315,6 +350,12 @@ public function send(): void } } + if ($this->streamCallback !== null) { + ($this->streamCallback)(new StreamWriter()); + + return; + } + echo $this->content; } diff --git a/src/Http/StreamWriter.php b/src/Http/StreamWriter.php new file mode 100644 index 0000000..d04f293 --- /dev/null +++ b/src/Http/StreamWriter.php @@ -0,0 +1,30 @@ +flush(); + } + + /** + * Flush any buffered stream output to the client. + */ + public function flush(): void + { + if (ob_get_level() > 0) { + ob_flush(); + } + + flush(); + } +} diff --git a/src/Http/StreamWriterInterface.php b/src/Http/StreamWriterInterface.php new file mode 100644 index 0000000..64aaba6 --- /dev/null +++ b/src/Http/StreamWriterInterface.php @@ -0,0 +1,18 @@ +content(); } + /** + * Configure the response to stream content when sent. + * + * @param callable(\Myxa\Http\StreamWriterInterface): void $callback + * @param array $headers + */ + public static function streaming(callable $callback, int $statusCode = 200, array $headers = []): HttpResponse + { + return self::getResponse()->streaming($callback, $statusCode, $headers); + } + /** * Set or replace a response header value. */ diff --git a/tests/Unit/Http/ResponseFacadeTest.php b/tests/Unit/Http/ResponseFacadeTest.php index 268ab8b..cd14c15 100644 --- a/tests/Unit/Http/ResponseFacadeTest.php +++ b/tests/Unit/Http/ResponseFacadeTest.php @@ -7,6 +7,7 @@ use BadMethodCallException; use JsonException; use Myxa\Http\Response as HttpResponse; +use Myxa\Http\StreamWriterInterface; use Myxa\Support\Facades\Response as ResponseFacade; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; @@ -99,6 +100,40 @@ public function testFacadeDelegatesToCurrentResponseInstance(): void } } + public function testFacadeSupportsStreamingResponses(): void + { + $response = new HttpResponse(); + + ResponseFacade::setResponse($response); + ResponseFacade::streaming(static function (StreamWriterInterface $stream): void { + $stream->write('hello'); + $stream->write(' world'); + }, 202, [ + 'Content-Type' => 'text/plain; charset=UTF-8', + ]); + + self::assertSame($response, ResponseFacade::getResponse()); + self::assertTrue($response->isStreaming()); + self::assertSame(202, ResponseFacade::statusCode()); + self::assertSame('text/plain; charset=UTF-8', ResponseFacade::header('content-type')); + + if (function_exists('header_remove')) { + header_remove(); + } + + ob_start(); + ob_start(); + ResponseFacade::send(); + ob_end_clean(); + $output = ob_get_clean(); + + self::assertSame('hello world', $output); + + if (function_exists('header_remove')) { + header_remove(); + } + } + public function testFacadeMagicCallStaticForwardsToUnderlyingResponse(): void { $response = (new HttpResponse())->body('magic'); diff --git a/tests/Unit/Http/ResponseTest.php b/tests/Unit/Http/ResponseTest.php index 1e0e252..c3817c9 100644 --- a/tests/Unit/Http/ResponseTest.php +++ b/tests/Unit/Http/ResponseTest.php @@ -7,10 +7,13 @@ use InvalidArgumentException; use JsonException; use Myxa\Http\Response; +use Myxa\Http\StreamWriter; +use Myxa\Http\StreamWriterInterface; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\TestCase; #[CoversClass(Response::class)] +#[CoversClass(StreamWriter::class)] final class ResponseTest extends TestCase { public function testResponseTracksStatusHeadersAndBody(): void @@ -122,6 +125,76 @@ public function testResponseSendOutputsBodyAndHandlesSameSiteCookies(): void } } + public function testResponseCanStreamContentWhenSent(): void + { + $response = (new Response('stale')) + ->setHeader('Content-Length', '999') + ->streaming(static function (StreamWriterInterface $stream): void { + $stream->write('chunk-1'); + $stream->write('chunk-2'); + }, 206, [ + 'Content-Type' => 'text/event-stream', + 'X-Accel-Buffering' => 'no', + ]); + + self::assertTrue($response->isStreaming()); + self::assertSame(206, $response->statusCode()); + self::assertSame('', $response->content()); + self::assertFalse($response->hasHeader('Content-Length')); + self::assertSame('text/event-stream', $response->header('content-type')); + self::assertSame('no', $response->header('x-accel-buffering')); + + if (function_exists('header_remove')) { + header_remove(); + } + + ob_start(); + ob_start(); + $response->send(); + ob_end_clean(); + $output = ob_get_clean(); + + self::assertSame('chunk-1chunk-2', $output); + + if (function_exists('header_remove')) { + header_remove(); + } + } + + public function testBodySwitchesResponseBackFromStreamingMode(): void + { + $response = (new Response()) + ->streaming(static function (StreamWriterInterface $stream): void { + $stream->write('stream'); + }) + ->body('plain'); + + self::assertFalse($response->isStreaming()); + + ob_start(); + $response->send(); + $output = ob_get_clean(); + + self::assertSame('plain', $output); + } + + public function testStreamWriterCanBeFlushedExplicitly(): void + { + $response = (new Response()) + ->streaming(static function (StreamWriterInterface $stream): void { + $stream->flush(); + $stream->write('chunk'); + }); + + ob_start(); + ob_start(); + $response->send(); + ob_end_clean(); + $output = ob_get_clean(); + + self::assertSame('chunk', $output); + } + public function testResponseRejectsInvalidStatusCode(): void { $this->expectException(InvalidArgumentException::class);