Skip to content

feat(php): add SSE / NDJSON / text streaming support#15882

Merged
patrickthornton merged 5 commits into
mainfrom
patrick/php/sse-streaming-support
May 14, 2026
Merged

feat(php): add SSE / NDJSON / text streaming support#15882
patrickthornton merged 5 commits into
mainfrom
patrick/php/sse-streaming-support

Conversation

@patrickthornton
Copy link
Copy Markdown
Contributor

@patrickthornton patrickthornton commented May 13, 2026

Closes FER-10530.

Summary

Adding PHP streaming support to match Python/Java/Go/etc.

Generated PHP SDKs now emit real streaming methods for endpoints with a response-stream instead of broken void methods that always throw. SSE, NDJSON, and raw text streaming all dispatch through a single unified Stream<T> runtime helper (Go's strategy from internal/streamer.go) with thin SseStream / JsonStream / TextStream wrappers for clean named return types.

What gets generated

For each response-stream endpoint, the generator picks the format from the IR's StreamingResponse union (sse / json / text) and emits a method whose return type is one of the three wrappers below. All three share a buffer-cap and BOM-safe line reader; the differences are framing and constructor signature.

SSE — SseStream<T>

Interface

final class SseStream<T> extends Stream<T>
{
    public function __construct(
        ResponseInterface $response,
        Closure $deserializer,                // fn(string $data): T
        ?string $terminator = '[DONE]',       // null disables terminator handling
        int $maxBufferSize = 1_048_576,       // 1 MiB cap on buffered data
    );

    /** @return Generator<int, T>           — yields payloads only (foreach) */
    public function getIterator(): Generator;

    /** @return Generator<int, SseEvent<T>> — yields payload + WHATWG metadata */
    public function events(): Generator;
}

final class SseEvent<T>
{
    public readonly T       $data;     // deserialized payload
    public readonly string  $event;    // `event:` field, '' if absent
    public readonly string  $id;       // last `id:` (persists per WHATWG)
    public readonly ?int    $retry;    // `retry:` ms, or null
}

Validates Content-Type: text/event-stream and rejects non-UTF-8 charsets. Handles multi-line data: (newline-joined), comment lines (: prefix), CRLF/CR normalization, and leading-space stripping per WHATWG §9.2.

Generated method

public function subscribe(?array $options = null): SseStream
{
    // ... sends request, captures $statusCode ...
    if ($statusCode >= 200 && $statusCode < 400) {
        return new SseStream(
            response: $response,
            deserializer: fn (string $data) => EventStreamResponse::fromJson($data),
            terminator: null,
        );
    }
    throw new Auth0ApiException(...);
}

Consumer usage

// Data-only iteration:
foreach ($auth0->events->subscribe() as $event) {
    match ($event->getType()) {
        'user.created' => handleUserCreated($event->getValue()),
        'offset-only'  => updateCursor($event->getValue()->getOffset()),
        default        => null,
    };
}

// With SSE metadata (event type, id for resume, retry hint):
foreach ($auth0->events->subscribe()->events() as $sse) {
    echo "event={$sse->event} id={$sse->id}\n";
    handlePayload($sse->data);
}

NDJSON — JsonStream<T>

Interface

final class JsonStream<T> extends Stream<T>
{
    public function __construct(
        ResponseInterface $response,
        Closure $deserializer,                // fn(string $line): T
        ?string $terminator = null,           // optional sentinel line
        int $maxBufferSize = 1_048_576,
    );

    /** @return Generator<int, T> */
    public function getIterator(): Generator;
}

One JSON document per line. Empty lines are skipped; a terminator line (if set) ends iteration cleanly.

Generated method

public function generateStream(GenerateStreamRequest $request, ?array $options = null): JsonStream
{
    // ... sends request, captures $statusCode ...
    if ($statusCode >= 200 && $statusCode < 400) {
        return new JsonStream(
            response: $response,
            deserializer: fn (string $data) => StreamResponse::fromJson($data),
            terminator: null,
        );
    }
    throw new SeedApiException(...);
}

Consumer usage

foreach ($client->dummy->generateStream($req) as $chunk) {
    echo $chunk->getId() . "\n";
}

Plain text — TextStream

Interface

final class TextStream extends Stream<string>
{
    public function __construct(
        ResponseInterface $response,
        int $maxBufferSize = 1_048_576,
    );

    /** @return Generator<int, string> */
    public function getIterator(): Generator;
}

No deserializer — yields each line of the response body as a raw string. CRLF/CR normalized to LF; UTF-8 BOM stripped if present.

Generated method

public function tail(?array $options = null): TextStream
{
    // ... sends request, captures $statusCode ...
    if ($statusCode >= 200 && $statusCode < 400) {
        return new TextStream(response: $response);
    }
    throw new SeedApiException(...);
}

Consumer usage (illustrative — no current fixture exercises text streaming end-to-end, but the codegen path is identical to NDJSON minus the deserializer)

foreach ($client->logs->tail() as $line) {
    echo $line . "\n";
}

Implementation

  • New runtime helper Stream.Template.php emitted into every generated SDK with streaming endpoints, plus thin SseStream / JsonStream / TextStream wrappers and an SseEvent<T> envelope for the metadata path.
  • IR's existing StreamingResponse._visit({ sse, json, text }) is now consumed by both the return-type generator and the success-branch emitter; no IR change needed.
  • Gated on ir.sdkConfig.hasStreamingEndpoints so SDKs without streaming endpoints are unaffected.
  • 13 new PHPUnit cases in StreamTest.Template.php covering the WHATWG SSE spec corners (multi-line data:, comment lines, CRLF, terminator, charset validation) plus JSON/text framing.

Test plan

  • pnpm seed test --generator php-sdk --fixture streaming — passes, NDJSON output now emits JsonStream<StreamResponse>.
  • pnpm seed test --generator php-sdk --fixture streaming-parameter — passes.
  • pnpm seed test --generator php-sdk --fixture server-sent-events — passes, emits SseStream<StreamedCompletion>.
  • pnpm seed test --generator php-sdk --fixture server-sent-events-openapi — passes (removed from allowedFailures).
  • pnpm seed test --generator php-sdk --fixture server-sent-event-examples — passes.
  • PHPStan and PHPUnit clean on regenerated seed/php-sdk/server-sent-events (65 tests, 233 assertions).
  • Manually generated Auth0's PHP SDK and verified EventsClient::subscribe() now produces a real SSE method. Wrote an integration test exercising MockHttpClient → RawClient → EventsClient::subscribe() → SseStream → discriminated-union dispatch; all 4 cases pass.

Copy link
Copy Markdown

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@patrickthornton patrickthornton self-assigned this May 13, 2026
@github-actions
Copy link
Copy Markdown
Contributor

github-actions Bot commented May 13, 2026

SDK Generation Benchmark Results

Comparing PR branch against median of 5 nightly run(s) on main (latest: 2026-05-14T05:16:07Z).

Full benchmark table (click to expand)
Generator Spec main (generator) main (E2E) PR (generator) Delta
php-sdk square 56s (n=5) 77s (n=5) 52s -4s (-7.1%)

main (generator): generator-only time via --skip-scripts (includes Docker image build, container startup, IR parsing, and code generation — this is the same Docker-based flow customers use via fern generate). main (E2E): full customer-observable time including build/test scripts (nightly baseline, informational). Delta is computed against generator-only baseline.
⚠️ = generation exited with a non-zero exit code (timing may not reflect a successful run).
Baseline from nightly runs on main (latest: 2026-05-14T05:16:07Z). Trigger benchmark-baseline to refresh.
Last updated: 2026-05-14 15:40 UTC

Three follow-ups to the SSE streaming runtime to close the parity gap
with the Python and Go SDK generators:

- New SseStream::events() yields typed SseEvent<T> with WHATWG envelope
  fields (event, id, retry). lastEventId persists across events per spec;
  null bytes in id and non-integer retry values are rejected.
- 1 MiB default maxBufferSize on every Stream wrapper, configurable per
  call. Guards line buffer and accumulated SSE event data against
  pathological streams; throws RuntimeException on overflow.
- SseStream constructor validates Content-Type contains text/event-stream
  and rejects non-UTF-8 charset parameters per the WHATWG SSE spec.

13 new PHPUnit cases cover metadata exposure, lastEventId persistence,
null-byte / non-integer field rejection, Content-Type / charset
validation, missing-header tolerance, and buffer overflow.
Six follow-ups to the SSE streaming runtime, surfacing from a fresh review
of Stream.Template.php for unidiomatic constructions:

1. Buffer cap during accumulation (real correctness gap): new
   appendWithinCap() helper throws BEFORE the concat allocates. A hostile
   stream sending many `data:` lines without a dispatching blank line
   used to grow $dataBuffer unbounded and only trip the check at
   dispatch — too late. Now bounded by the configured limit.

2. Split StreamFormat into its own template file for PSR-4 autoload
   correctness. Generated SDKs ship a separate StreamFormat.php.

3. Encapsulate $deserializer: private field behind a protected
   deserialize() method. SseStream::events() now calls
   $this->deserialize() instead of accessing the closure directly.

4. Strip a leading UTF-8 BOM per WHATWG §9.2.4. Handles BOM split
   across read chunks via a deferred check.

5. DEFAULT_MAX_BUFFER_SIZE = 1_048_576 (numeric separator).

6. Stream::__construct is now protected — external callers must use
   SseStream, JsonStream, or TextStream. Wrappers' parent::__construct
   calls are unaffected.

Adds three new tests: cumulative-accumulation cap, BOM at start, BOM
not stripped mid-stream.
…SDKs

Trim WHAT-narration and internal implementation rationale from comments
in Stream.Template.php and StreamTest.Template.php — these files are
emitted into every generated SDK and were exposing dev-side context
("a hostile stream cannot drive us past the limit and then trip the
check", "The constructor is protected to enforce this", etc.) that
customers don't need. WHATWG spec citations and non-obvious WHY
comments are kept.
- Collapse duplicated enumString switch arm into the primitive case in
  buildStreamDeserializerBody (single decode$Suffix dispatch).
- Stream::readLines now guards str_replace(["\r\n", "\r"], "\n", ...)
  with str_contains($chunk, "\r"); NDJSON / text streams no longer pay
  the per-chunk rewrite when no CR bytes are present.
Copy link
Copy Markdown
Contributor

@iamnamananand996 iamnamananand996 left a comment

Choose a reason for hiding this comment

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

Looking at the seeds looks good

@patrickthornton patrickthornton merged commit b0dec74 into main May 14, 2026
79 checks passed
@patrickthornton patrickthornton deleted the patrick/php/sse-streaming-support branch May 14, 2026 20:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants