feat(typespec-ts): streaming - #5196
Conversation
…ault JSONL/SSE stream responses now return Promise<AsyncIterable<T>> of deserialized items/events instead of the raw binary Uint8Array body. JsonlStream<T> lazily decodes JSON Lines; SSEStream<T> dispatches each Server-Sent Event by its event: name (via @azure/core-sse), deserializes the payload, and stops at the terminal event. Heterogeneous multi-event dispatch and terminal-event handling are supported. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Adds the workspace link entries for @typespec/streams, @typespec/sse and @typespec/events that were added to packages/typespec-ts/package.json for structured JSONL/SSE streaming. Required so CI's frozen-lockfile install matches package.json. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
|
All changed packages have been documented.
Show changes
|
Brings the branch up to date with upstream main so CI can build the PR merge commit. Resolves the conflict in pnpm-lock.yaml (took upstream and re-added the @typespec/streams, @typespec/sse and @typespec/events workspace links) and adapts the structured-streaming deserialize helper to the refactored getExceptionThrowStatement signature (now [string[], ServiceOperation]). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
📦 Package size report1 package changed size, +12.43 KB (+0.1%) packed overall.
12 package(s) with no notable change
Packed = gzipped |
commit: |
|
You can try these changes here
|
Three fixes from a design review of the default JSONL/SSE structured streaming feature: 1. Split the SSE decoder into a separate sseStreamingHelpers.ts so JSONL-only packages no longer import (or need) @azure/core-sse. streamingHelpers.ts now contains only JSONL + shared helpers with no core-sse dependency. SseStreamingHelpers loads only when the package has an SSE operation, matching the @azure/core-sse dependency gate. 2. Only treat a constant SSE event as a terminal sentinel when isTerminalEvent is true. A non-terminal constant is a real payload variant and is now deserialized/yielded and included in the item type instead of silently terminating the stream. 3. Thread the event payloadContentType into each SSE descriptor and only JSON.parse JSON payloads; non-JSON (e.g. text/plain) payloads are passed through as the raw data string. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Opt the http-specs streaming/sse scenario (unnamed, named+terminal, retrieve) into the typespec-ts integration suite so the default structured-streaming codegen is exercised end-to-end against the real spector mock. The generated public surface confirms the operations return Promise<AsyncIterable<T>> with heterogeneous per-event dispatch and terminal-event handling. JSONL is deferred from the integration baseline: its send() op exercises request-body (upload) stream typing, a pre-existing emitter gap unrelated to this response-streaming feature. JSONL response streaming remains covered by the unit-modular snapshot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Remove unrelated @typespec/http-client-python 0.35.1 churn from the lock and keep only the @typespec/events, @typespec/sse, and @typespec/streams workspace links required by typespec-ts's new streaming dependencies. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Add @azure/core-sse devDependency (with clean lockfile entries) and a runtime integration test that exercises the generated SSE clients against the http-specs mock server: unnamed message events, heterogeneous named events with terminal [DONE] handling, and event-name-dispatched retrieve streaming. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Move the @azure/core-sse version into the pnpm-workspace.yaml catalog and reference it via catalog: in package.json, matching the repo policy that all external dependencies use the catalog protocol. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
| azure/client-generator-core/client-doc: true | ||
| azure/client-generator-core/response-as-bool: true | ||
| documentation: true | ||
| streaming/sse: true |
There was a problem hiding this comment.
is there anything else that should be added?
Merge jsonlStreaming.md, sseUnnamedStreaming.md, and sseNamedStreaming.md into a single streaming.md with three scenarios (the scenario runner splits each file on H1, so one file holds multiple scenarios). Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
…turedStreamInfo Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
…ating Two structured-streaming correctness fixes: - Non-terminal SSE events whose payload needs no model deserializer (primitive/scalar/enum) were silently dropped because no deserialize callback was emitted. Emit an identity deserializer so the raw payload (JSON-parsed value, or raw string for non-JSON content) is yielded. Add a regression scenario mixing a model and a text/plain scalar event. - packageHasSseStreaming now mirrors getStructuredStreamInfo's gating (paging/LRO exclusions + streamMetadata & sseMetadata requirement) so @azure/core-sse is only injected when SSE streaming is actually generated. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Maor Leger (maorleger)
left a comment
There was a problem hiding this comment.
This is coming along quite nicely!! Thanks so much for all the effort in getting this together.
I still have lots to review, but the overall direction seems ok. I noted a few findings that we should fix though
| } | ||
|
|
||
| const streamType = streamMetadata.streamType; | ||
| if (streamType.kind !== "model" && streamType.kind !== "union") { |
There was a problem hiding this comment.
I'm not sure, but would it be better to use the contentTypes in SdkStreamMetadata to determine whether this is an sse or jsonl stream?
- application/jsonl
- text/event-stream
I just worry that something like a custom stream template might be incorrectly interpreted as jsonl but we should verify, I might just be thinking about it wrong and it may be a non-issue
| event.contentType = sseEvent.payloadContentType; | ||
| } | ||
| if (!sseEvent.isTerminalEvent) { | ||
| payloadTypeExpressions.push(getTypeExpression(context, sseEvent.payloadType)); |
There was a problem hiding this comment.
Can you have a terminal event with data / model? If so, would this lose type information?
To help me understand a bit, I asked
and decided to shamelessly paste its output here verbatim 😄
terminal event *with a model payload* still gets a deserializer. The helper yields it before the
generator returns (`sseStreamingHelpers.ts:78-85`). Executed against the PR's own helper bodies:
| Case | Observed | Problem |
| --- | --- | --- |
| Terminal event, model payload | `[{desc:"a"}, {desc:"FINAL"}]` | Yielded, but its type is absent from the declared union — unsound |
| Terminal event, primitive payload | `[{desc:"a"}]` | Silently dropped — inconsistent with the model case |
| Unrelated `event: delta` carrying `data: [DONE]` | `[]` | Sentinel matched on data only; the wrong event ends the stream |
The third case happens because the terminal check runs before descriptor matching. It ignores the
event name.
**Impact**: a typed terminal event often carries the final logical result. Runtime delivers it, but
the type system cannot see it. Consumers need a cast to narrow to it.
**Fix**: `@terminalEvent` says only that the client must disconnect. It does not dictate whether the
triggering event is yielded. Pick one policy: yield typed terminal events, or consume all terminal
events internally. Then make the runtime value, declared item union, and primitive/model paths
match. Name-sensitive sentinel matching is secondary hardening, not part of the blocker.
> **Cross-language note**: matching the terminal by data value mirrors Python's `terminal_event`
> with `event.data == self._terminal_event`. That half is a deliberate shared choice. Raise
> name-matching with both languages.There was a problem hiding this comment.
So from my understanding the case we are supporting now is something like the following:
data: hello this is blah
data: [DONE]
where @terminalEvent in typespec is defined as "[DONE]"
There was a problem hiding this comment.
I see, ok so as long as we're guaranteeing that the terminal event is just a sentinel value and doesnt carry data this should be fine. Thanks for confirming! 👍
There was a problem hiding this comment.
I went looking at the search typespec instead of just the spector tests and my understanding is actually wrong they have a terminal event as a model not a literal in their typespec that this would need to support:
@terminalEvent
response.completed: KnowledgeBaseResponseCompletedEvent
so I believe we want to yield it to look like:
{event: response.completed; data: KnowledgeBaseResponseCompletedEvent} ?
There was a problem hiding this comment.
Oh nice find!! Thanks for digging into it. And yea, what you're proposing makes sense at first glance but I trust your judgement
| const events = await collect<ResponseCreated | ResponseDelta>(await client.named.receive()); | ||
| // Terminal `data: [DONE]` must not be yielded. | ||
| assert.strictEqual(events.length, 3); | ||
| assert.strictEqual((events[0] as ResponseCreated).id, "resp_1"); |
There was a problem hiding this comment.
I think we probably want to keep the event name for better type information?
Something like:
export type NamedReceiveEvent =
| { event: "responseCreated"; data: ResponseCreated }
| { event: "responseDelta"; data: ResponseDelta };Will allow the user to narrow down the event based on the kind like in this playground
In general it's probably better to keep the envelope shape if one is defined. I noticed TCGC has that information. I think JSONL needs to stay payload only though
There was a problem hiding this comment.
you can have named and unamed events here based on the spector test like Info vs responseCreated: Info - so if there is an unamed event it will just be the payload as well
There was a problem hiding this comment.
I see, but for the case of an envelope see here, just scrolling down a bit how do we represent it in other languages? Do we drop the envelope there as well and just return the payload?
There was a problem hiding this comment.
It looks like we dont have spector tests for envelope scenarios yet (@data), so at least in python I did not have support for them yet
| if (descriptor.deserialize) { | ||
| const payload = event.data | ||
| ? isJsonContentType(descriptor.contentType) | ||
| ? JSON.parse(event.data) |
There was a problem hiding this comment.
hmm ok so if there's a JSON parse error, the customer loses all information about the event like event name, status, etc.
This is minor, since it involves an edge case (invalid JSON payload) but still, would it make sense to throw a more useful error here?
try {
const payload = event.data
? isJsonContentType(descriptor.contentType)
? JSON.parse(event.data)
catch (e) {
throw new Error(`Unable to deserialize event ${event.event} as JSON", { cause: e });
}
...
Or something along those lines?- Add streamingHelpers-browser.mts: uses asBrowserStream() and converts ReadableStream to AsyncIterable - Add streamingHelpers-react-native.mts: re-exports browser variant - Add sseStreamingHelpers-browser.mts: re-exports base (body already AsyncIterable) - Add sseStreamingHelpers-react-native.mts: re-exports browser variant - Fix terminal event name-aware matching in sseStreamingHelpers.ts (check descriptor by name first) - Add content-type validation for JSONL vs SSE stream detection - Add named event type discrimination for SSE return types (discriminated union) - Update streaming test baselines for named event discriminated unions - Clarify in SSE integration test that discriminated union enables type-safe narrowing Follows the same pattern as get-binary-response polyfills. Enables: - Node.js: StreamableMethod.asNodeStream() → AsyncIterable (default) - Browser: StreamableMethod.asBrowserStream() → ReadableStream → AsyncIterable - React-Native: Same as browser (browser APIs available) Addresses PR review comments: - r3815393091: Environment polyfills - r3815434566: Content-type validation - r3815531940: Terminal event name-aware matching - r3815653900: Named event type discrimination with discriminated union Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5de9bff8-f217-4c66-9e30-bdc80256fed8
c503c07 to
1a2adc2
Compare
Include the SSE event name when JSON payload parsing fails and preserve the original error as the cause. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5de9bff8-f217-4c66-9e30-bdc80256fed8
Generate discriminated union return types for heterogeneous named SSE streams so callers can narrow payloads by event name. Update streaming operation baselines. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 5de9bff8-f217-4c66-9e30-bdc80256fed8
…ed events in an envelope
Terminal events are now always consumed internally and never yielded, so the
declared item union is sound by construction. The terminal check runs after
descriptor resolution by event name (an unrelated event carrying the sentinel
data can no longer end the stream) and before deserialization (terminal
payloads are never yielded).
Named SSE events are wrapped in a { event, data } envelope so the generated
return type matches what the runtime helper yields. The envelope is applied
all-or-nothing via everyPayloadEventIsNamed to avoid a union mixing envelope
and bare shapes.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Regenerate the SSE integration declaration baseline so named event envelopes match the generated operation return types. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: de6a6ea9-d1f8-4a50-831f-d64d9f7cfa0d
Summary
Adds structured JSONL + SSE streaming code generation to the
@azure-tools/typespec-tsmodular emitter. A response whose TCGC metadata indicates a structured stream now generates aPromise<AsyncIterable<T>>of deserialized items/events instead of returning the raw binaryUint8Arraybody.What is generated
JsonlStream<T>) ->Promise<AsyncIterable<T>>that lazily decodes JSON Lines.SSEStream<T>) ->Promise<AsyncIterable<...>>that dispatches each Server-Sent Event by itsevent:name (via@azure/core-sse), deserializes the payload, and stops at the terminal event. Full heterogeneous multi-event dispatch and@terminalEventhandling are supported (named terminals keyed on the terminal flag; unnameddata: [DONE]sentinel terminals). Generation is driven by TCGCstreamMetadata/sseMetadata.Operations connect eagerly (HTTP status/errors surface at call time) and decode the body lazily.
Implementation
static/static-helpers/streamingHelpers.ts(getStreamResponse,readJsonlStream,ensureStreamStatus) andsseStreamingHelpers.ts(readSseStream,SseEventDescriptor), registered instatic-helpers-metadata.tsand loaded only when the package has a structured-stream operation (packageHasStructuredStreaming/packageHasSseStreaming, side-effect-free metadata checks).sseStreamingHelpers.tsis the only file importing@azure/core-sse.operation-helpers.ts:getStructuredStreamInforesolves stream metadata (gated purely onresponse.streamMetadata/sseMetadata); branches in the operation and deserialize builders emit the AsyncIterable shape.@azure/core-sseadded toAzureCoreDependenciesand injected into the generated package's dependencies only when an SSE op is present.