Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -83,7 +83,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
php: ['8.3', '8.4']
php: ['8.3', '8.4', '8.5']
stability: [prefer-stable]
include:
- os: ubuntu-latest
Expand Down
19 changes: 18 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,23 @@
# Release Notes

## [Unreleased](https://github.com/Thavarshan/fetch-php/compare/3.5.1...HEAD)
## [Unreleased](https://github.com/Thavarshan/fetch-php/compare/3.6.0...HEAD)

## [v3.6.0](https://github.com/Thavarshan/fetch-php/compare/3.5.1...3.6.0) - 2026-07-21

### Added

- **Streaming responses and Server-Sent Events** for consuming response bodies incrementally instead of buffering them into memory:
- `Fetch\Http\StreamedResponse` — an unbuffered response returned by the new `stream()` methods; pull raw chunks via `stream()`, newline-delimited lines via `lines()`, iterate the object directly, or fall back to a buffered `Response` with `buffer()`.
- `Fetch\Http\EventSource` — a WHATWG-compliant `text/event-stream` parser that lazily yields events and tracks `lastEventId()`/`reconnectionTime()`.
- `Fetch\Http\ServerSentEvent` — an immutable event value object with `data`, `type`, `id`, and `retry` fields plus `json()` and `isDone()` helpers.
- `stream()` and `sse()` methods on `ClientHandler` and `Client`, exposed through the `RequestExecutor` interface.
- `fetch_stream()` and `fetch_sse()` global helper functions mirroring the existing `fetch()`/`get()` style.
- `ContentType::EVENT_STREAM` and `ContentType::NDJSON` cases with `isEventStream()`/`isStreamable()` helpers.
- Added PHP 8.5 to the CI test matrix across Ubuntu, Windows, and macOS.

### Changed

- Streaming requests are synchronous and intentionally bypass the response cache, consistent with the library's existing sync-only caching behaviour.

## [v3.5.1](https://github.com/Thavarshan/fetch-php/compare/3.5.0...3.5.1) - 2026-06-06

Expand Down
17 changes: 17 additions & 0 deletions CODE_MAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ This reference captures the concrete public surface of the FetchPHP library as i
- Maintains a static `Client` instance; `reset=true` recreates it.
- Passing `$options` clones the current handler with merged defaults (exceptions are wrapped in `RuntimeException` with context).
- Verb helpers `get/post/put/patch/delete()` call `request_method()` which coerces array bodies to JSON unless `$dataIsQuery` is true.
- Streaming helpers:
- `fetch_stream(string $url, ?array $options = []): Fetch\Interfaces\StreamedResponse` – returns an unbuffered `StreamedResponse`; the body is pulled incrementally via `stream()`/`lines()` rather than read into memory. Method defaults to GET (override with `$options['method']`).
- `fetch_sse(string $url, ?array $options = []): Fetch\Http\EventSource` – consumes a `text/event-stream` response as lazily-yielded `Fetch\Http\ServerSentEvent` objects; adds an `Accept: text/event-stream` header automatically.
- Matrix async bridge helpers (`async`, `await`, `all`, `race`, `map`, `batch`, `retry`) are re-exported when the corresponding `\Matrix\*` functions exist.
- Internal helpers:
- `process_request_options(array $options)` normalizes method enums/strings, headers, body precedence (`json` > `form` > `multipart` > `body`) and high-level flags.
Expand Down Expand Up @@ -96,6 +99,20 @@ Key runtime behaviors:
- Debugging hooks: `withDebugInfo(DebugInfo $info)` and `getDebugInfo()` to inspect per-request snapshots.
- `ResponseImmutabilityTrait` keeps buffered body contents in sync when streams are replaced.

### `Fetch\Http\StreamedResponse`

- Extends `GuzzleHttp\Psr7\Response`, implements `Fetch\Interfaces\StreamedResponse` + `IteratorAggregate`.
- Returned by `ClientHandler::stream()` / `Client::stream()` / `fetch_stream()` when a request runs with `stream => true`. The body is **not** buffered.
- Consumption: `stream(int $chunkSize = 8192)` yields raw chunks; `lines()` yields newline-delimited lines (handles `\r\n` and chunk boundaries); iterating the object itself yields chunks.
- `sse()` wraps the stream in an `EventSource`; `buffer()` drains the remaining stream into a normal `Fetch\Http\Response`.
- Mirrors `Response` status/header helpers: `status()`, `statusEnum()`, `ok()`, `failed()`, `headers()`, `header()`, `contentType()`, `contentTypeEnum()`.

### `Fetch\Http\EventSource` & `Fetch\Http\ServerSentEvent`

- `EventSource` (implements `IteratorAggregate`) parses a `text/event-stream` body per the WHATWG SSE algorithm, yielding events lazily via `events()` (or direct iteration). Tracks `lastEventId()` and `reconnectionTime()` across the stream.
- `ServerSentEvent` – immutable DTO with `data`, `type`, `id`, `retry`; helpers `json()` (decode payload), `isDone()` (detects the `[DONE]` sentinel), and `__toString()`.
- Returned by `ClientHandler::sse()` / `Client::sse()` / `fetch_sse()`. Streaming is synchronous and bypasses the response cache.

---

## Support Services
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ Full documentation can be found [here](https://fetch-php.thavarshan.com/)
- **Promise-based API**: Use familiar `.then()`, `.catch()`, and `.finally()` methods for async operations
- **Fluent Interface**: Build requests with a clean, chainable API
- **Built on Guzzle**: Benefit from Guzzle's robust functionality with a more elegant API
- **Streaming & Server-Sent Events**: Consume response bodies incrementally (`response.body`-style) and parse `text/event-stream` responses — ideal for streaming LLM APIs and live feeds
- **Retry Mechanics**: Configurable retry logic with exponential backoff for transient failures
- **RFC 7234 HTTP Caching**: Full caching support with ETag/Last-Modified revalidation, stale-while-revalidate, and stale-if-error
- **Connection Pooling**: Reuse TCP connections across requests with global connection pool and DNS caching
Expand Down
4 changes: 4 additions & 0 deletions docs/.vitepress/config.mts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,10 @@ export default defineConfig({
text: "Connection Pooling & HTTP/2",
link: "/guide/connection-pooling",
},
{
text: "Streaming & Server-Sent Events",
link: "/guide/streaming",
},
{ text: "File Uploads", link: "/guide/file-uploads" },
{
text: "Custom Clients",
Expand Down
128 changes: 128 additions & 0 deletions docs/guide/streaming.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
---
title: Streaming & Server-Sent Events
description: Consume response bodies incrementally and parse text/event-stream responses for streaming LLM APIs and live feeds.
---

# Streaming & Server-Sent Events

Most responses in Fetch PHP are fully buffered—the body is read into memory and exposed through helpers like `json()` and `text()`. That is the right default for typical API calls, but it breaks down for large downloads and for endpoints that emit data over time, such as streaming LLM completions.

For those cases Fetch PHP provides an unbuffered path that mirrors JavaScript's `response.body`: a [`StreamedResponse`](#streamedresponse) you pull chunks or lines from as they arrive, plus a first-class [Server-Sent Events](#server-sent-events) consumer.

> Streaming is **synchronous** and **bypasses the response cache**. The body is never read into memory up front, so a streamed response can only be traversed once—use `buffer()` if you need random access.

## Streaming a response body

Use the `fetch_stream()` helper, or `->stream()` on a client/handler. Nothing is read until you iterate.

```php
use function fetch_stream;

$response = fetch_stream('https://example.com/large-export.csv');

// Pull raw chunks (default 8 KB) as they arrive.
foreach ($response->stream() as $chunk) {
echo $chunk;
}
```

Prefer `lines()` for text protocols—it handles `\n`/`\r\n` and reassembles lines split across chunk boundaries:

```php
foreach (fetch_stream('https://example.com/events.log')->lines() as $line) {
process($line);
}
```

The fluent API works too, so you can attach headers, auth, or a base URI first:

```php
use Fetch\Enum\Method;

$response = fetch_client()
->getHandler()
->withToken($apiKey)
->stream(Method::GET, 'https://example.com/feed');

if ($response->ok()) {
foreach ($response->lines() as $line) {
// ...
}
}
```

### Falling back to a buffered response

Call `buffer()` to drain the remaining stream into a regular `Fetch\Http\Response`:

```php
$response = fetch_stream('https://api.example.com/report');

$data = $response->buffer()->json();
```

## Server-Sent Events

`fetch_sse()` (or `->sse()`) returns an `EventSource` that lazily yields `ServerSentEvent` objects parsed according to the WHATWG Server-Sent Events specification. An `Accept: text/event-stream` header is added automatically.

```php
use function fetch_sse;

$events = fetch_sse('https://api.example.com/v1/stream');

foreach ($events as $event) {
if ($event->isDone()) { // detects the "[DONE]" sentinel many APIs send
break;
}

$payload = $event->json(); // decode the data: field as JSON
echo $payload['delta'] ?? '';
}
```

### Streaming an LLM completion

SSE endpoints are frequently `POST` requests. Pass a method and body through the options array:

```php
$events = fetch_sse('https://api.example.com/v1/chat/completions', [
'method' => 'POST',
'headers' => ['Authorization' => "Bearer {$apiKey}"],
'json' => [
'model' => 'example-model',
'stream' => true,
'messages' => [
['role' => 'user', 'content' => 'Write a haiku about PHP.'],
],
],
]);

foreach ($events as $event) {
if ($event->isDone()) {
break;
}

$delta = $event->json()['choices'][0]['delta']['content'] ?? '';
echo $delta;
}
```

### The `ServerSentEvent` object

Each dispatched event exposes the parsed fields:

| Property / method | Description |
| ----------------- | ----------- |
| `data` | The event payload (multiple `data:` lines are joined with `\n`). |
| `type` | The event type from the `event:` field (defaults to `"message"`). |
| `id` | The last event ID from the `id:` field, if any. |
| `retry` | The reconnection time in milliseconds from the `retry:` field, if any. |
| `json()` | Decode `data` as JSON. |
| `isDone()` | Whether `data` is the conventional `[DONE]` termination sentinel. |

The `EventSource` also tracks stream-level state via `lastEventId()` and `reconnectionTime()`.

## When to stream

- **Stream** for large downloads, long-lived feeds, and any `text/event-stream` endpoint (streaming AI/LLM APIs, live dashboards, log tails).
- **Buffer** (the default `fetch()`/`get()`/`post()` helpers) for ordinary JSON APIs where you want the whole response at once and features like caching and retries.
100 changes: 100 additions & 0 deletions src/Fetch/Concerns/PerformsHttpRequests.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
use Fetch\Enum\ContentType;
use Fetch\Enum\Method;
use Fetch\Exceptions\RequestException as FetchRequestException;
use Fetch\Http\EventSource;
use Fetch\Http\Response;
use Fetch\Http\StreamedResponse;
use Fetch\Interfaces\Response as ResponseInterface;
use Fetch\Interfaces\StreamedResponse as StreamedResponseInterface;
use Fetch\Support\RequestContext;
use Fetch\Support\RequestOptions;
use GuzzleHttp\Exception\GuzzleException;
Expand Down Expand Up @@ -145,6 +148,57 @@ public function options(string $uri): ResponseInterface|PromiseInterface
return $this->sendRequest(Method::OPTIONS, $uri);
}

/**
* Send a request and return an unbuffered, streamable response.
*
* The response body is not read into memory; callers pull chunks or lines
* as the server produces them (see {@see StreamedResponse}). Streaming is
* always synchronous and bypasses the response cache.
*
* @param array<string, mixed> $options Additional options
*/
public function stream(
Method|string $method = Method::GET,
string $uri = '',
array $options = [],
): StreamedResponseInterface {
$options['stream'] = true;

return $this->sendStreamingRequest($method, $uri, $options);
}

/**
* Send a request and consume the response as Server-Sent Events.
*
* Adds an `Accept: text/event-stream` header when none is present and
* returns an {@see EventSource} yielding parsed {@see ServerSentEvent}s.
*
* @param array<string, mixed> $options Additional options
*/
public function sse(
Method|string $method = Method::GET,
string $uri = '',
array $options = [],
): EventSource {
$headers = $options['headers'] ?? [];

$hasAccept = false;
foreach (array_keys($headers) as $name) {
if (strcasecmp((string) $name, 'Accept') === 0) {
$hasAccept = true;
break;
}
}

if (! $hasAccept) {
$headers['Accept'] = ContentType::EVENT_STREAM->value;
}

$options['headers'] = $headers;

return new EventSource($this->stream($method, $uri, $options));
}

/**
* Send an HTTP request.
*
Expand Down Expand Up @@ -339,6 +393,52 @@ public function getEffectiveTimeout(?RequestContext $context = null): int
return self::DEFAULT_TIMEOUT;
}

/**
* Execute a synchronous streaming request and wrap the raw PSR-7 response.
*
* @param array<string, mixed> $options Additional options
*/
protected function sendStreamingRequest(
Method|string $method,
string $uri,
array $options = [],
): StreamedResponseInterface {
$methodStr = $method instanceof Method ? $method->value : strtoupper($method);

// Streaming is inherently synchronous: force async off for this path.
$requestOptions = RequestOptions::merge(
$this->options,
$options,
['method' => $methodStr, 'uri' => $uri, 'async' => false, 'stream' => true],
);

RequestOptions::validate($requestOptions);

$context = RequestContext::fromOptions($requestOptions);
$fullUri = $this->buildFullUriFromContext($context);
$guzzleOptions = $context->toGuzzleOptions();
$guzzleOptions['stream'] = true;

$requestId = $this->startProfiling($methodStr, $fullUri);

if (method_exists($this, 'logRequest')) {
$this->logRequest($methodStr, $fullUri, $guzzleOptions);
}

try {
$this->recordProfilingEvent($requestId, 'request_sent');

$psrResponse = $this->getHttpClient()->request($methodStr, $fullUri, $guzzleOptions);

$this->recordProfilingEvent($requestId, 'response_start');
$this->endProfiling($requestId, $psrResponse->getStatusCode());

return StreamedResponse::createFromBase($psrResponse);
} catch (\Throwable $e) {
throw $this->withErrorContext($e, $methodStr, $fullUri);
}
}

/**
* Apply body-related options without mutating handler state.
*
Expand Down
24 changes: 23 additions & 1 deletion src/Fetch/Enum/ContentType.php
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ enum ContentType: string
case ZIP = 'application/zip';
case JAVASCRIPT = 'application/javascript';
case CSS = 'text/css';
case EVENT_STREAM = 'text/event-stream';
case NDJSON = 'application/x-ndjson';

/**
* Get a content type from a string.
Expand Down Expand Up @@ -85,11 +87,31 @@ public function isText(): bool
{
return match ($this) {
// These are text-based content types
self::JSON, self::FORM_URLENCODED, self::TEXT, self::HTML, self::XML, self::CSV => true,
self::JSON, self::FORM_URLENCODED, self::TEXT, self::HTML, self::XML, self::CSV, self::EVENT_STREAM, self::NDJSON => true,
// These are binary/non-text content types
self::MULTIPART => false,
// Default for any new enum values added in the future
default => false,
};
}

/**
* Check if the content type is a Server-Sent Events stream.
*/
public function isEventStream(): bool
{
return $this === self::EVENT_STREAM;
}

/**
* Check if the content type is typically consumed as an incremental
* stream rather than a single buffered payload (e.g. SSE or NDJSON).
*/
public function isStreamable(): bool
{
return match ($this) {
self::EVENT_STREAM, self::NDJSON => true,
default => false,
};
}
}
Loading
Loading