Skip to content

feat(typespec-ts): streaming - #5196

Draft
Libba Lawrence (l0lawrence) wants to merge 20 commits into
Azure:mainfrom
l0lawrence:l0lawrence-vigilant-system
Draft

feat(typespec-ts): streaming#5196
Libba Lawrence (l0lawrence) wants to merge 20 commits into
Azure:mainfrom
l0lawrence:l0lawrence-vigilant-system

Conversation

@l0lawrence

@l0lawrence Libba Lawrence (l0lawrence) commented Aug 10, 2026

Copy link
Copy Markdown
Member

Summary

Adds structured JSONL + SSE streaming code generation to the @azure-tools/typespec-ts modular emitter. A response whose TCGC metadata indicates a structured stream now generates a Promise<AsyncIterable<T>> of deserialized items/events instead of returning the raw binary Uint8Array body.

What is generated

  • JSONL (JsonlStream<T>) -> Promise<AsyncIterable<T>> that lazily decodes JSON Lines.
  • SSE (SSEStream<T>) -> Promise<AsyncIterable<...>> that dispatches each Server-Sent Event by its event: name (via @azure/core-sse), deserializes the payload, and stops at the terminal event. Full heterogeneous multi-event dispatch and @terminalEvent handling are supported (named terminals keyed on the terminal flag; unnamed data: [DONE] sentinel terminals). Generation is driven by TCGC streamMetadata / sseMetadata.

Operations connect eagerly (HTTP status/errors surface at call time) and decode the body lazily.

Implementation

  • New static helpers static/static-helpers/streamingHelpers.ts (getStreamResponse, readJsonlStream, ensureStreamStatus) and sseStreamingHelpers.ts (readSseStream, SseEventDescriptor), registered in static-helpers-metadata.ts and loaded only when the package has a structured-stream operation (packageHasStructuredStreaming / packageHasSseStreaming, side-effect-free metadata checks). sseStreamingHelpers.ts is the only file importing @azure/core-sse.
  • operation-helpers.ts: getStructuredStreamInfo resolves stream metadata (gated purely on response.streamMetadata / sseMetadata); branches in the operation and deserialize builders emit the AsyncIterable shape.
  • @azure/core-sse added to AzureCoreDependencies and injected into the generated package's dependencies only when an SSE op is present.

…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
@microsoft-github-policy-service microsoft-github-policy-service Bot added the emitter:typescript Issues for @azure-tools/typespec-ts emitter label Aug 10, 2026
@l0lawrence Libba Lawrence (l0lawrence) changed the title feat(typespec-ts): generate structured JSONL and SSE streaming by default feat(typespec-ts): streaming Aug 10, 2026
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
@github-actions

Copy link
Copy Markdown
Contributor

All changed packages have been documented.

  • @azure-tools/typespec-ts
Show changes

@azure-tools/typespec-ts - feature ✏️

Generate structured JSONL and SSE streaming operations by default. A JSONL or SSE stream response now returns Promise<AsyncIterable<T>> of deserialized items/events instead of the previous raw binary Uint8Array body. An operation returning JsonlStream<T> lazily decodes JSON Lines into AsyncIterable<T>, and an operation returning SSEStream<T> returns an AsyncIterable of the event payload types, dispatching each Server-Sent Event by its event: name (using @azure/core-sse), deserializing each payload, and stopping at the terminal event. This is the default behavior — there is no opt-in option.

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
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

📦 Package size report

1 package changed size, +12.43 KB (+0.1%) packed overall.

Package Packed (base → head) Δ Packed Unpacked (base → head) Δ Unpacked
@azure-tools/typespec-ts 527.17 KB → 538.77 KB +11.59 KB (+2.2%) 🔴 2.54 MB → 2.59 MB +54.04 KB (+2.1%) 🔴
12 package(s) with no notable change
Package Packed (base → head) Δ Packed Unpacked (base → head) Δ Unpacked
@azure-tools/typespec-client-generator-core 227.76 KB → 228.79 KB +1.03 KB (+0.5%) 1.23 MB → 1.23 MB +1.85 KB (+0.1%)
@azure-tools/typespec-go 258.55 KB → 258.42 KB -138 B (-0.1%) 1.32 MB → 1.32 MB -671 B (-0.0%)
@azure-tools/typespec-java 13.51 MB → 13.51 MB -76 B (-0.0%) 15.03 MB → 15.03 MB +65 B (+0.0%)
@azure-tools/typespec-python 42.20 KB → 42.22 KB +20 B (+0.0%) 164.85 KB → 164.91 KB +65 B (+0.0%)
@azure-tools/typespec-azure-portal-core 42.40 KB → 42.41 KB +1 B (+0.0%) 192.91 KB → 192.91 KB
@azure-tools/azure-http-specs 146.63 KB → 146.63 KB 1.16 MB → 1.16 MB
@azure-tools/typespec-autorest 80.93 KB → 80.93 KB 395.06 KB → 395.06 KB
@azure-tools/typespec-autorest-canonical 7.42 KB → 7.42 KB 26.00 KB → 26.00 KB
@azure-tools/typespec-azure-core 129.43 KB → 129.43 KB 702.77 KB → 702.77 KB
@azure-tools/typespec-azure-resource-manager 171.92 KB → 171.92 KB 1.04 MB → 1.04 MB
@azure-tools/typespec-azure-rulesets 5.16 KB → 5.16 KB 32.09 KB → 32.09 KB
@azure-tools/typespec-metadata 15.91 KB → 15.91 KB 62.26 KB → 62.26 KB

Packed = gzipped .tgz published to npm. Unpacked = total extracted size. 🆕 added, 🗑️ removed. Packages from the core/ submodule are not included.
🔴 grew · 🟢 shrank — only changes of at least 512 B and 0.5% are marked.

@pkg-pr-new

pkg-pr-new Bot commented Aug 10, 2026

Copy link
Copy Markdown

Open in StackBlitz

npm i https://pkg.pr.new/@azure-tools/typespec-ts@5196

commit: c96ee67

@azure-sdk-automation

Copy link
Copy Markdown
Contributor

You can try these changes here

🛝 Playground 🌐 Website

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

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.

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

@maorleger Maor Leger (maorleger) left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

Comment thread packages/typespec-ts/static/static-helpers/streamingHelpers.ts
}

const streamType = streamMetadata.streamType;
if (streamType.kind !== "model" && streamType.kind !== "union") {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Can you have a terminal event with data / model? If so, would this lose type information?

To help me understand a bit, I asked :copilot: 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.

@l0lawrence Libba Lawrence (l0lawrence) Aug 20, 2026

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.

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]"

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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! 👍

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 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} ?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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");

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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

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.

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

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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?

@l0lawrence Libba Lawrence (l0lawrence) Aug 20, 2026

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.

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)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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
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
const yielded = useEventEnvelope
? `({ event: ${JSON.stringify(event.eventName)}, data: ${payloadExpression} })`
: payloadExpression;
parts.push(`deserialize: (data) => ${yielded}`);
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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

emitter:typescript Issues for @azure-tools/typespec-ts emitter

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants